diff --git a/mobile/app/batch-review.tsx b/mobile/app/batch-review.tsx index 4c06cd4..fb3ba7f 100644 --- a/mobile/app/batch-review.tsx +++ b/mobile/app/batch-review.tsx @@ -1,9 +1,10 @@ import React, { useState, useCallback } from 'react'; -import { View, Text, StyleSheet, FlatList, TouchableOpacity, Image, Alert, ActivityIndicator } from 'react-native'; +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; @@ -22,6 +23,7 @@ export function BatchReviewScreen() { const batch = getBatch(route.params.batchId); const [selectedIds, setSelectedIds] = useState>(new Set()); + const [confirmDeleteVisible, setConfirmDeleteVisible] = useState(false); if (!batch) { return ( @@ -44,22 +46,12 @@ export function BatchReviewScreen() { const handleDelete = () => { if (selectedCount === 0) return; - const label = selectedCount === 1 ? 'cette photo' : `ces ${selectedCount} photos`; - Alert.alert( - 'Supprimer', - `Retirer ${label} du lot ?`, - [ - { text: 'Annuler', style: 'cancel' }, - { - text: 'Supprimer', - style: 'destructive', - onPress: () => { - selectedIds.forEach((id) => removePhotoFromBatch(batch.id, id)); - setSelectedIds(new Set()); - }, - }, - ] - ); + setConfirmDeleteVisible(true); + }; + + const handleDeleteConfirm = () => { + selectedIds.forEach((id) => removePhotoFromBatch(batch.id, id)); + setSelectedIds(new Set()); }; const handleGroup = () => { @@ -131,6 +123,17 @@ export function BatchReviewScreen() { + + setConfirmDeleteVisible(false)} + /> ); } diff --git a/mobile/app/file-detail.tsx b/mobile/app/file-detail.tsx index cd29191..632ab25 100644 --- a/mobile/app/file-detail.tsx +++ b/mobile/app/file-detail.tsx @@ -10,7 +10,6 @@ import { Pressable, TouchableOpacity, Share, - Alert, } from 'react-native'; import { Image } from 'expo-image'; import { MaterialIcons } from '@expo/vector-icons'; @@ -20,6 +19,7 @@ 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'; @@ -100,6 +100,7 @@ function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; dev 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); @@ -136,39 +137,30 @@ function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; dev } }, [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); - 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]); + setConfirmDeleteVisible(true); + }, []); const togglePanelJS = useCallback(() => { if (isPanelExpanded.value) { @@ -387,6 +379,17 @@ function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; dev + + setConfirmDeleteVisible(false)} + /> ); } diff --git a/mobile/app/folder.tsx b/mobile/app/folder.tsx index c89188c..d42d44f 100644 --- a/mobile/app/folder.tsx +++ b/mobile/app/folder.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState, useCallback, useEffect } from 'react'; -import { View, FlatList, StyleSheet, TouchableOpacity, Text, Dimensions, Alert, Modal, TextInput } from 'react-native'; +import { View, FlatList, StyleSheet, TouchableOpacity, Text, Dimensions, 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'; @@ -9,6 +9,7 @@ import { 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 { FileThumbnail } from '../components/FileThumbnail'; @@ -104,6 +105,7 @@ export function FolderScreen() { 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; @@ -142,12 +144,10 @@ export function FolderScreen() { return f?.syncStatus === 'synced'; }); const label = ids.length === 1 ? 'ce fichier' : `ces ${ids.length} fichiers`; - const options: Array<{ text: string; style?: 'default' | 'cancel' | 'destructive'; onPress?: () => void }> = [ - { text: 'Annuler', style: 'cancel' }, - ]; + const options: ConfirmOption[] = []; if (hasSynced) { options.push({ - text: 'Du device uniquement', + label: 'Du device uniquement', onPress: async () => { const syncedIds = ids.filter((id) => { const f = files.find((fi) => fi.id === id); @@ -161,8 +161,8 @@ export function FolderScreen() { }); } options.push({ - text: 'Du device + serveur', - style: 'destructive', + label: 'Du device + serveur', + destructive: true, onPress: async () => { for (const id of ids) { const f = files.find((fi) => fi.id === id); @@ -177,11 +177,12 @@ export function FolderScreen() { } downloadRegistry.remove(id); } - await queryClient.invalidateQueries({ queryKey: ['files'] }); + await queryClient.invalidateQueries({ queryKey: ['resources'] }); setSelectedIds(new Set()); }, }); - Alert.alert('Supprimer', `Supprimer ${label} ?`, options); + options.push({ label: 'Annuler' }); + setConfirmDeleteState({ message: `Supprimer ${label} ?`, options }); }, [selectedIds, files, deleteFile, freeLocalSpace, queryClient]); const handleEdit = useCallback(() => { @@ -358,6 +359,14 @@ export function FolderScreen() { + + setConfirmDeleteState(null)} + /> ); } diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index e37b2c2..f8523f9 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -12,6 +12,7 @@ import { UnifiedFileItem, isFolder } from '../types'; import { SearchBar, SearchFilters, MediaFilter } from '../components/SearchBar'; import { FileThumbnail } from '../components/FileThumbnail'; import { SettingsModal } from '../components/SettingsModal'; +import { ConfirmModal, type ConfirmOption } from '../components/ConfirmModal'; import { UploadModal } from '../components/UploadModal'; import { SyncStatusIcon } from '../components/SyncStatusIcon'; import { useSyncQueue } from '../hooks/useSyncQueue'; @@ -119,6 +120,8 @@ export function HomeScreen() { const [uploadModalVisible, setUploadModalVisible] = useState(false); const [globalSyncMode, setGlobalSyncMode] = useState(() => safDirectory.getGlobalSyncMode()); const [globalSyncCellular, setGlobalSyncCellular] = useState(() => safDirectory.getGlobalSyncCellular()); + const [confirmDeleteState, setConfirmDeleteState] = useState<{ message: string; options: ConfirmOption[] } | null>(null); + const [removeFolderConfirmId, setRemoveFolderConfirmId] = useState(null); const { pendingCount, isSyncing } = useSyncQueue(); useAutoSync(); @@ -247,12 +250,10 @@ export function HomeScreen() { return f?.syncStatus === 'synced'; }); const label = ids.length === 1 ? 'ce fichier' : `ces ${ids.length} fichiers`; - const options: Array<{ text: string; style?: 'default' | 'cancel' | 'destructive'; onPress?: () => void }> = [ - { text: 'Annuler', style: 'cancel' }, - ]; + const options: ConfirmOption[] = []; if (hasSynced) { options.push({ - text: 'Du device uniquement', + label: 'Du device uniquement', onPress: async () => { const syncedIds = ids.filter((id) => { const f = files.find((fi) => fi.id === id); @@ -266,8 +267,8 @@ export function HomeScreen() { }); } options.push({ - text: 'Du device + serveur', - style: 'destructive', + label: 'Du device + serveur', + destructive: true, onPress: async () => { for (const id of ids) { const f = files.find((fi) => fi.id === id); @@ -282,11 +283,12 @@ export function HomeScreen() { } downloadRegistry.remove(id); } - await queryClient.invalidateQueries({ queryKey: ['files'] }); + await queryClient.invalidateQueries({ queryKey: ['resources'] }); setSelectedIds(new Set()); }, }); - Alert.alert('Supprimer', `Supprimer ${label} ?`, options); + options.push({ label: 'Annuler' }); + setConfirmDeleteState({ message: `Supprimer ${label} ?`, options }); }, [selectedIds, files, freeLocalSpace, queryClient]); const handleEdit = useCallback(() => { @@ -339,22 +341,16 @@ export function HomeScreen() { }, [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]); + setRemoveFolderConfirmId(folderId); + }, []); + + const handleRemoveFolderConfirm = useCallback(() => { + if (removeFolderConfirmId) { + safDirectory.removeFolder(removeFolderConfirmId); + refreshFolders(); + } + setRemoveFolderConfirmId(null); + }, [removeFolderConfirmId, refreshFolders]); const handleAddFolder = useCallback(async () => { setSettingsModalVisible(false); @@ -650,6 +646,25 @@ export function HomeScreen() { globalSyncCellular={globalSyncCellular} onSetGlobalSyncCellular={handleSetGlobalSyncCellular} /> + + setConfirmDeleteState(null)} + /> + + setRemoveFolderConfirmId(null)} + /> ); } diff --git a/mobile/components/ConfirmModal.tsx b/mobile/components/ConfirmModal.tsx new file mode 100644 index 0000000..5a2c964 --- /dev/null +++ b/mobile/components/ConfirmModal.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Modal } from 'react-native'; + +export interface ConfirmOption { + label: string; + onPress?: () => void; + destructive?: boolean; +} + +interface ConfirmModalProps { + visible: boolean; + title: string; + message?: string; + options: ConfirmOption[]; + onClose: () => void; +} + +export function ConfirmModal({ visible, title, message, options, onClose }: ConfirmModalProps) { + const stacked = options.length > 2; + + return ( + + + {}}> + + {title} + {message ? {message} : null} + + {options.map((opt, i) => ( + { + onClose(); + opt.onPress?.(); + }} + > + + {opt.label} + + + ))} + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.4)', + justifyContent: 'center', + alignItems: 'center', + }, + container: { + backgroundColor: '#fff', + borderRadius: 16, + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: 20, + width: '85%', + }, + handle: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: '#ddd', + alignSelf: 'center', + marginBottom: 16, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: '#333', + marginBottom: 8, + }, + message: { + fontSize: 14, + color: '#666', + lineHeight: 20, + marginBottom: 20, + }, + optionsContainer: { + flexDirection: 'row', + gap: 10, + }, + optionsStacked: { + flexDirection: 'column', + gap: 0, + }, + sideBtn: { + flex: 1, + paddingVertical: 12, + borderRadius: 10, + alignItems: 'center', + }, + sideDestructive: { + backgroundColor: '#E53935', + }, + sideBtnSecondary: { + backgroundColor: '#f5f5f5', + }, + sideBtnText: { + fontSize: 15, + fontWeight: '600', + }, + sideDestructiveText: { + color: '#fff', + }, + sideBtnSecondaryText: { + color: '#666', + }, + stackedBtn: { + paddingVertical: 14, + alignItems: 'center', + }, + stackedBtnBorder: { + borderBottomWidth: 1, + borderBottomColor: '#f0f0f0', + }, + stackedDestructive: {}, + stackedSecondary: {}, + stackedBtnText: { + fontSize: 16, + fontWeight: '600', + }, + stackedDestructiveText: { + color: '#E53935', + }, + stackedSecondaryText: { + color: '#333', + }, +}); diff --git a/mobile/components/SettingsModal.tsx b/mobile/components/SettingsModal.tsx index ba055c3..f5b1fd4 100644 --- a/mobile/components/SettingsModal.tsx +++ b/mobile/components/SettingsModal.tsx @@ -6,10 +6,10 @@ import { TouchableOpacity, Modal, ScrollView, - Alert, } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import { StoredFolder, SyncMode, SyncGlobalMode, type FolderSource } from '../services/safDirectory'; +import { ConfirmModal } from './ConfirmModal'; type SettingsView = 'menu' | 'folders' | 'sync'; @@ -57,6 +57,7 @@ export function SettingsModal({ onSetGlobalSyncCellular, }: SettingsModalProps) { const [view, setView] = useState('menu'); + const [confirmRemoveFolder, setConfirmRemoveFolder] = useState(null); const handleClose = useCallback(() => { setView('menu'); @@ -65,22 +66,18 @@ export function SettingsModal({ const handleRemoveFolder = useCallback( (folder: StoredFolder) => { - 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: () => onRemoveFolder(folder.id), - }, - ] - ); + setConfirmRemoveFolder(folder); }, - [onRemoveFolder] + [] ); + const handleRemoveFolderConfirm = useCallback(() => { + if (confirmRemoveFolder) { + onRemoveFolder(confirmRemoveFolder.id); + } + setConfirmRemoveFolder(null); + }, [confirmRemoveFolder, onRemoveFolder]); + return ( + + setConfirmRemoveFolder(null)} + /> ); }