add files listing
This commit is contained in:
@@ -40,3 +40,6 @@ yarn-error.*
|
|||||||
# generated native folders
|
# generated native folders
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
|
||||||
|
modules/*/android/build/
|
||||||
|
modules/*/android/.gradle/
|
||||||
+10
-1
@@ -17,6 +17,8 @@ import { PendingReviewScreen } from './app/pending-review';
|
|||||||
import { FileDetailScreen } from './app/file-detail';
|
import { FileDetailScreen } from './app/file-detail';
|
||||||
import { FileEditScreen } from './app/file-edit';
|
import { FileEditScreen } from './app/file-edit';
|
||||||
import { FolderScreen } from './app/folder';
|
import { FolderScreen } from './app/folder';
|
||||||
|
import { OnboardingScreen } from './app/onboarding';
|
||||||
|
import { onboardingStorage } from './services/onboardingStorage';
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator();
|
const Stack = createNativeStackNavigator();
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
@@ -32,11 +34,18 @@ function AppNavigator() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const needsOnboarding = user && onboardingStorage.needsOnboarding();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationContainer>
|
<NavigationContainer>
|
||||||
<Stack.Navigator initialRouteName={user ? 'Home' : 'Login'}>
|
<Stack.Navigator initialRouteName={needsOnboarding ? 'Onboarding' : user ? 'Home' : 'Login'}>
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Onboarding"
|
||||||
|
component={OnboardingScreen}
|
||||||
|
options={{ headerShown: false }}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Home"
|
name="Home"
|
||||||
component={HomeScreen}
|
component={HomeScreen}
|
||||||
|
|||||||
+3
-1
@@ -20,7 +20,9 @@
|
|||||||
"READ_EXTERNAL_STORAGE",
|
"READ_EXTERNAL_STORAGE",
|
||||||
"WRITE_EXTERNAL_STORAGE",
|
"WRITE_EXTERNAL_STORAGE",
|
||||||
"READ_MEDIA_IMAGES",
|
"READ_MEDIA_IMAGES",
|
||||||
"READ_MEDIA_VIDEO"
|
"READ_MEDIA_VIDEO",
|
||||||
|
"READ_MEDIA_AUDIO",
|
||||||
|
"RECEIVE_BOOT_COMPLETED"
|
||||||
],
|
],
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#E6F4FE",
|
||||||
|
|||||||
+25
-15
@@ -30,25 +30,35 @@ type SelectedImage = { uri: string; width: number; height: number };
|
|||||||
|
|
||||||
type ModalState = { images: SelectedImage[]; index: number } | null;
|
type ModalState = { images: SelectedImage[]; index: number } | null;
|
||||||
|
|
||||||
|
export type DeviceFileParam = {
|
||||||
|
localUri: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
type RootStackParamList = {
|
type RootStackParamList = {
|
||||||
FileDetail: { fileIds: string[]; initialIndex: number };
|
FileDetail: { fileIds: string[]; initialIndex: number; deviceFiles?: Record<string, DeviceFileParam> };
|
||||||
};
|
};
|
||||||
|
|
||||||
type FileDetailRouteProp = RouteProp<RootStackParamList, 'FileDetail'>;
|
type FileDetailRouteProp = RouteProp<RootStackParamList, 'FileDetail'>;
|
||||||
|
|
||||||
function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?: (state: ModalState) => void }) {
|
function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; deviceFile?: DeviceFileParam; onSelectImage?: (state: ModalState) => void }) {
|
||||||
const { data: imageData, isLoading: imageLoading } = useFileImage(fileId);
|
const isDevice = !!deviceFile;
|
||||||
const { data: fileData } = useFile(fileId);
|
const { data: imageData, isLoading: imageLoading } = useFileImage(isDevice ? '' : fileId);
|
||||||
|
const { data: fileData } = useFile(isDevice ? '' : fileId);
|
||||||
const downloadFile = useDownloadFile();
|
const downloadFile = useDownloadFile();
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
const uri = imageData?.data?.url;
|
const uri = isDevice ? deviceFile.localUri : (imageData?.data?.url);
|
||||||
const file = fileData as any;
|
const file = fileData as any;
|
||||||
|
|
||||||
const localEntry = localFileRegistry.getByBackendId(fileId);
|
const localEntry = localFileRegistry.getByBackendId(fileId);
|
||||||
const syncStatus: SyncStatus = localEntry
|
const syncStatus: SyncStatus = isDevice
|
||||||
? (localEntry.syncStatus === 'cloud' ? 'cloud' : 'synced')
|
? 'local'
|
||||||
: (uri ? 'synced' : 'cloud');
|
: localEntry
|
||||||
|
? (localEntry.syncStatus === 'cloud' ? 'cloud' : 'synced')
|
||||||
|
: (uri ? 'synced' : 'cloud');
|
||||||
|
|
||||||
const fullThumbnails: Thumbnail[] = (file?.data?.thumbnails ?? [])
|
const fullThumbnails: Thumbnail[] = (file?.data?.thumbnails ?? [])
|
||||||
.filter((t: Thumbnail) => t.resolutionLabel === 'full')
|
.filter((t: Thumbnail) => t.resolutionLabel === 'full')
|
||||||
@@ -141,19 +151,19 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?:
|
|||||||
|
|
||||||
<View style={styles.details}>
|
<View style={styles.details}>
|
||||||
<View style={styles.detailHeader}>
|
<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} />
|
<SyncStatusBadge status={syncStatus} size={20} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{imageData?.data?.size != null && (
|
{(deviceFile || imageData?.data?.size != null) && (
|
||||||
<Text style={styles.meta}>
|
<Text style={styles.meta}>
|
||||||
Taille : {(imageData.data.size / 1024).toFixed(1)} Ko
|
Taille : {((deviceFile ? 0 : imageData?.data?.size) ?? 0 / 1024).toFixed(1)} Ko
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{file?.createdAt && (
|
{(deviceFile?.createdAt || file?.createdAt) && (
|
||||||
<Text style={styles.meta}>
|
<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',
|
day: 'numeric', month: 'long', year: 'numeric',
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -183,7 +193,7 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?:
|
|||||||
|
|
||||||
export function FileDetailScreen() {
|
export function FileDetailScreen() {
|
||||||
const route = useRoute<FileDetailRouteProp>();
|
const route = useRoute<FileDetailRouteProp>();
|
||||||
const { fileIds, initialIndex } = route.params;
|
const { fileIds, initialIndex, deviceFiles } = route.params;
|
||||||
|
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
@@ -223,7 +233,7 @@ export function FileDetailScreen() {
|
|||||||
}}
|
}}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View style={styles.pageWrapper}>
|
<View style={styles.pageWrapper}>
|
||||||
<DetailItem fileId={item} onSelectImage={setModalState} />
|
<DetailItem fileId={item} deviceFile={deviceFiles?.[item]} onSelectImage={setModalState} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+174
-2
@@ -10,6 +10,7 @@ import { UnifiedFileItem } from '../hooks/useUnifiedFiles';
|
|||||||
import { FileItem, isFolder } from '../types';
|
import { FileItem, isFolder } from '../types';
|
||||||
import { SearchBar, SearchFilters } from '../components/SearchBar';
|
import { SearchBar, SearchFilters } from '../components/SearchBar';
|
||||||
import { FileThumbnail } from '../components/FileThumbnail';
|
import { FileThumbnail } from '../components/FileThumbnail';
|
||||||
|
import { safDirectory, StoredFolder } from '../services/safDirectory';
|
||||||
|
|
||||||
const NUM_COLUMNS = 3;
|
const NUM_COLUMNS = 3;
|
||||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||||
@@ -19,7 +20,7 @@ type RootStackParamList = {
|
|||||||
Home: undefined;
|
Home: undefined;
|
||||||
Upload: undefined;
|
Upload: undefined;
|
||||||
Scan: 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[] };
|
FileEdit: { fileIds: string[] };
|
||||||
Folder: { folderId: string; folderName: string };
|
Folder: { folderId: string; folderName: string };
|
||||||
};
|
};
|
||||||
@@ -133,7 +134,7 @@ function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilte
|
|||||||
export function HomeScreen() {
|
export function HomeScreen() {
|
||||||
const navigation = useNavigation<NavigationProp>();
|
const navigation = useNavigation<NavigationProp>();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { data, isLoading, error, hasPermission, requestPermission } = useUnifiedFiles();
|
const { data, isLoading, error, hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useUnifiedFiles();
|
||||||
const deleteFile = useDeleteFile();
|
const deleteFile = useDeleteFile();
|
||||||
const freeLocalSpace = useFreeLocalSpace();
|
const freeLocalSpace = useFreeLocalSpace();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -148,6 +149,7 @@ export function HomeScreen() {
|
|||||||
const moveFiles = useMoveFiles();
|
const moveFiles = useMoveFiles();
|
||||||
const { data: foldersData } = useFolders();
|
const { data: foldersData } = useFolders();
|
||||||
const [moveModalVisible, setMoveModalVisible] = useState(false);
|
const [moveModalVisible, setMoveModalVisible] = useState(false);
|
||||||
|
const [folderSheetVisible, setFolderSheetVisible] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
|
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
|
||||||
@@ -155,6 +157,16 @@ export function HomeScreen() {
|
|||||||
return () => { show.remove(); hide.remove(); };
|
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 selectionMode = selectedIds.size > 0;
|
||||||
|
|
||||||
const files = data ?? [];
|
const files = data ?? [];
|
||||||
@@ -269,15 +281,50 @@ export function HomeScreen() {
|
|||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
}, [selectedIds, moveFiles]);
|
}, [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) => {
|
const handleItemPress = useCallback((file: UnifiedFileItem) => {
|
||||||
if (selectionMode) {
|
if (selectionMode) {
|
||||||
toggleSelection(file.id);
|
toggleSelection(file.id);
|
||||||
} else if (isFolder(file)) {
|
} else if (isFolder(file)) {
|
||||||
navigation.navigate('Folder', { folderId: file.id, folderName: file.name });
|
navigation.navigate('Folder', { folderId: file.id, folderName: file.name });
|
||||||
} else {
|
} 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', {
|
navigation.navigate('FileDetail', {
|
||||||
fileIds: filteredFiles.map((f) => f.id),
|
fileIds: filteredFiles.map((f) => f.id),
|
||||||
initialIndex: fileIdToIndex.get(file.id) ?? 0,
|
initialIndex: fileIdToIndex.get(file.id) ?? 0,
|
||||||
|
deviceFiles: Object.keys(deviceFilesMap).length > 0 ? deviceFilesMap : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [selectionMode, toggleSelection, navigation, filteredFiles, fileIdToIndex]);
|
}, [selectionMode, toggleSelection, navigation, filteredFiles, fileIdToIndex]);
|
||||||
@@ -333,6 +380,14 @@ export function HomeScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</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
|
<FlatList
|
||||||
data={groupKeys}
|
data={groupKeys}
|
||||||
keyExtractor={(item) => item}
|
keyExtractor={(item) => item}
|
||||||
@@ -528,6 +583,45 @@ export function HomeScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Modal>
|
</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>
|
</KeyboardAvoidingView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -561,6 +655,19 @@ const styles = StyleSheet.create({
|
|||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
|
folderScanBanner: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFF3E0',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
folderScanText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
center: {
|
center: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -777,4 +884,69 @@ const styles = StyleSheet.create({
|
|||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontWeight: '600',
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export const CURRENT_ONBOARDING_VERSION = 1;
|
||||||
|
|
||||||
|
export type OnboardingAction = {
|
||||||
|
type: 'pick_directory';
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnboardingStep = {
|
||||||
|
id: string;
|
||||||
|
version: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
action?: OnboardingAction;
|
||||||
|
condition?: 'has_no_folders';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||||
|
{
|
||||||
|
id: 'welcome',
|
||||||
|
version: 1,
|
||||||
|
title: 'Bienvenue sur Dot.',
|
||||||
|
description: 'Votre espace document personnel, toujours accessible.',
|
||||||
|
icon: 'waving-hand',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'folder_explanation',
|
||||||
|
version: 1,
|
||||||
|
title: 'Pourquoi un dossier dédié ?',
|
||||||
|
description: 'Depuis Android 13, les applications ne peuvent plus accéder librement au dossier Téléchargements (par sécurité).\n\nDot. utilise un mécanisme spécial (SAF) pour lire vos fichiers. En créant un dossier dédié, vous autorisez l\'application à y accéder en toute sécurité.\n\n→ Déplacez vos documents dans ce dossier et ils apparaîtront automatiquement dans Dot.',
|
||||||
|
icon: 'folder-off',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'create_folder',
|
||||||
|
version: 1,
|
||||||
|
title: 'Créez votre dossier',
|
||||||
|
description: 'Sélectionnez ou créez un dossier dans le sélecteur ci-dessous.',
|
||||||
|
icon: 'create-new-folder',
|
||||||
|
action: { type: 'pick_directory', label: 'Sélectionner un dossier' },
|
||||||
|
condition: 'has_no_folders',
|
||||||
|
},
|
||||||
|
];
|
||||||
+175
-11
@@ -1,6 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import * as MediaLibrary from 'expo-media-library/legacy';
|
import * as MediaLibrary from 'expo-media-library/legacy';
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import { LocalFileEntry } from '../types';
|
import { LocalFileEntry } from '../types';
|
||||||
|
import { safDirectory, type StoredFolder } from '../services/safDirectory';
|
||||||
|
import { downloadRegistry } from '../services/downloadRegistry';
|
||||||
|
import { useFileWatcher } from './useFileWatcher';
|
||||||
|
import { FileDetectedEvent } from '../modules/expo-download-detect';
|
||||||
|
|
||||||
export interface DeviceFile {
|
export interface DeviceFile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -9,29 +14,92 @@ export interface DeviceFile {
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
size: number;
|
size: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
folderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessMimeType(name: string): string {
|
||||||
|
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
doc: 'application/msword',
|
||||||
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
xls: 'application/vnd.ms-excel',
|
||||||
|
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
ppt: 'application/vnd.ms-powerpoint',
|
||||||
|
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
txt: 'text/plain',
|
||||||
|
csv: 'text/csv',
|
||||||
|
json: 'application/json',
|
||||||
|
xml: 'application/xml',
|
||||||
|
zip: 'application/zip',
|
||||||
|
rar: 'application/x-rar-compressed',
|
||||||
|
mp4: 'video/mp4',
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
png: 'image/png',
|
||||||
|
gif: 'image/gif',
|
||||||
|
webp: 'image/webp',
|
||||||
|
};
|
||||||
|
return map[ext] ?? 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventToDeviceFile(event: FileDetectedEvent): DeviceFile {
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
uri: event.uri,
|
||||||
|
name: event.name,
|
||||||
|
mimeType: event.mimeType,
|
||||||
|
size: event.size,
|
||||||
|
createdAt: new Date(event.createdAt).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanSafFolder(folder: StoredFolder): Promise<DeviceFile[]> {
|
||||||
|
try {
|
||||||
|
const entries = await FileSystem.StorageAccessFramework.readDirectoryAsync(folder.uri);
|
||||||
|
const files: DeviceFile[] = [];
|
||||||
|
for (const entryUri of entries) {
|
||||||
|
const parts = entryUri.split('/');
|
||||||
|
const name = decodeURIComponent(parts[parts.length - 1]);
|
||||||
|
if (name.startsWith('.')) continue;
|
||||||
|
files.push({
|
||||||
|
id: `saf_${folder.id}_${entryUri}`,
|
||||||
|
uri: entryUri,
|
||||||
|
name,
|
||||||
|
mimeType: guessMimeType(name),
|
||||||
|
size: 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
folderId: folder.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[useDeviceFiles] SAF scan error for folder "${folder.name}":`, err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDeviceFiles() {
|
export function useDeviceFiles() {
|
||||||
const [files, setFiles] = useState<DeviceFile[]>([]);
|
const [files, setFiles] = useState<DeviceFile[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [hasPermission, setHasPermission] = useState(false);
|
const [hasPermission, setHasPermission] = useState(false);
|
||||||
|
const [folders, setFolders] = useState<StoredFolder[]>(() => safDirectory.getAll());
|
||||||
|
const { newFiles, clearNewFiles } = useFileWatcher();
|
||||||
|
|
||||||
const requestPermission = useCallback(async () => {
|
const requestPermission = useCallback(async () => {
|
||||||
const req = await MediaLibrary.requestPermissionsAsync();
|
const req = await MediaLibrary.requestPermissionsAsync();
|
||||||
console.log('[useDeviceFiles] requestPermission status:', req.status, 'granted:', req.granted, 'canAskAgain:', req.canAskAgain);
|
|
||||||
if (req.granted) {
|
if (req.granted) {
|
||||||
setHasPermission(true);
|
setHasPermission(true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const check = await MediaLibrary.getPermissionsAsync();
|
const check = await MediaLibrary.getPermissionsAsync();
|
||||||
console.log('[useDeviceFiles] getPermissionsAsync status:', check.status, 'granted:', check.granted);
|
|
||||||
const granted = check.granted;
|
const granted = check.granted;
|
||||||
setHasPermission(granted);
|
setHasPermission(granted);
|
||||||
return granted;
|
return granted;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadAssets = useCallback(async () => {
|
const loadAssets = useCallback(async () => {
|
||||||
console.log('[useDeviceFiles] loadAssets called');
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await MediaLibrary.getAssetsAsync({
|
const result = await MediaLibrary.getAssetsAsync({
|
||||||
@@ -52,7 +120,6 @@ export function useDeviceFiles() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
setFiles(deviceFiles);
|
setFiles(deviceFiles);
|
||||||
console.log('[useDeviceFiles] loaded', deviceFiles.length, 'files');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[useDeviceFiles] scan error:', err);
|
console.error('[useDeviceFiles] scan error:', err);
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
@@ -61,23 +128,120 @@ export function useDeviceFiles() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const scanVisibleFolders = useCallback(async () => {
|
||||||
requestPermission().then((granted) => {
|
const visibleFolders = safDirectory.getVisibleFolders();
|
||||||
console.log('[useDeviceFiles] mount permission result:', granted);
|
if (visibleFolders.length === 0) return;
|
||||||
if (granted) {
|
|
||||||
loadAssets();
|
const safFiles: DeviceFile[] = [];
|
||||||
|
for (const folder of visibleFolders) {
|
||||||
|
const folderFiles = await scanSafFolder(folder);
|
||||||
|
safFiles.push(...folderFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles((prev) => {
|
||||||
|
const existing = new Set(prev.filter((f) => !f.folderId).map((f) => f.id));
|
||||||
|
const mediaOnly = prev.filter((f) => !f.folderId);
|
||||||
|
const merged = [...mediaOnly];
|
||||||
|
for (const f of safFiles) {
|
||||||
|
if (!existing.has(f.id)) merged.push(f);
|
||||||
}
|
}
|
||||||
|
return merged;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadRegistryFiles = useCallback(() => {
|
||||||
|
const registryEntries = downloadRegistry.getAll();
|
||||||
|
const registryFiles: DeviceFile[] = registryEntries.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
uri: entry.uri,
|
||||||
|
name: entry.name,
|
||||||
|
mimeType: entry.mimeType,
|
||||||
|
size: entry.size,
|
||||||
|
createdAt: new Date(entry.createdAt).toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setFiles((prev) => {
|
||||||
|
const existing = new Set(prev.map((f) => f.id));
|
||||||
|
const merged = [...prev];
|
||||||
|
for (const f of registryFiles) {
|
||||||
|
if (!existing.has(f.id)) merged.push(f);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pickDirectory = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
|
||||||
|
if (!result.granted) return false;
|
||||||
|
const dirUri = result.directoryUri;
|
||||||
|
const parts = dirUri.split('/');
|
||||||
|
const dirName = decodeURIComponent(parts[parts.length - 1] ?? 'Dossier');
|
||||||
|
safDirectory.addFolder(dirUri, dirName);
|
||||||
|
setFolders(safDirectory.getAll());
|
||||||
|
|
||||||
|
const folderFiles = await scanSafFolder({ id: 'new', uri: dirUri, name: dirName, visible: true });
|
||||||
|
setFiles((prev) => {
|
||||||
|
const existing = new Set(prev.map((f) => f.id));
|
||||||
|
const merged = [...prev];
|
||||||
|
for (const f of folderFiles) {
|
||||||
|
if (!existing.has(f.id)) merged.push(f);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[useDeviceFiles] pickDirectory error:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshFolders = useCallback(() => {
|
||||||
|
setFolders(safDirectory.getAll());
|
||||||
|
}, []);
|
||||||
|
|
||||||
const rescan = useCallback(async () => {
|
const rescan = useCallback(async () => {
|
||||||
const granted = await requestPermission();
|
const granted = await requestPermission();
|
||||||
if (granted) {
|
if (granted) {
|
||||||
await loadAssets();
|
await loadAssets();
|
||||||
}
|
}
|
||||||
}, [requestPermission, loadAssets]);
|
await scanVisibleFolders();
|
||||||
|
loadRegistryFiles();
|
||||||
|
}, [requestPermission, loadAssets, scanVisibleFolders, loadRegistryFiles]);
|
||||||
|
|
||||||
return { files, isLoading, hasPermission, requestPermission, rescan };
|
// Handle new files detected by native module
|
||||||
|
useEffect(() => {
|
||||||
|
if (newFiles.length === 0) return;
|
||||||
|
|
||||||
|
// Persist to MMKV
|
||||||
|
downloadRegistry.addBatch(newFiles);
|
||||||
|
|
||||||
|
// Add to in-memory state
|
||||||
|
const newDeviceFiles = newFiles.map(eventToDeviceFile);
|
||||||
|
setFiles((prev) => {
|
||||||
|
const existing = new Set(prev.map((f) => f.id));
|
||||||
|
const merged = [...prev];
|
||||||
|
for (const f of newDeviceFiles) {
|
||||||
|
if (!existing.has(f.id)) merged.push(f);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
|
||||||
|
clearNewFiles();
|
||||||
|
}, [newFiles, clearNewFiles]);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
requestPermission().then((granted) => {
|
||||||
|
if (granted) {
|
||||||
|
loadAssets();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
scanVisibleFolders();
|
||||||
|
loadRegistryFiles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { files, isLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deviceFileToLocalEntry(deviceFile: DeviceFile): LocalFileEntry {
|
export function deviceFileToLocalEntry(deviceFile: DeviceFile): LocalFileEntry {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import { ExpoDownloadDetectModule, FileDetectedEvent } from '../modules/expo-download-detect';
|
||||||
|
|
||||||
|
export function useFileWatcher() {
|
||||||
|
const [newFiles, setNewFiles] = useState<FileDetectedEvent[]>([]);
|
||||||
|
const [isSupported] = useState(() => Platform.OS === 'android');
|
||||||
|
|
||||||
|
const clearNewFiles = useCallback(() => {
|
||||||
|
setNewFiles([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSupported) return;
|
||||||
|
|
||||||
|
ExpoDownloadDetectModule.startWatching();
|
||||||
|
|
||||||
|
const subscription = ExpoDownloadDetectModule.addListener('onNewFile', (event: FileDetectedEvent) => {
|
||||||
|
setNewFiles((prev) => {
|
||||||
|
if (prev.some((f) => f.id === event.id)) return prev;
|
||||||
|
return [...prev, event];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
ExpoDownloadDetectModule.stopWatching();
|
||||||
|
};
|
||||||
|
}, [isSupported]);
|
||||||
|
|
||||||
|
return { newFiles, clearNewFiles, isSupported };
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { localFileRegistry } from '../services/localFileRegistry';
|
|||||||
import { LocalFileEntry } from '../types';
|
import { LocalFileEntry } from '../types';
|
||||||
|
|
||||||
export function useLocalFiles() {
|
export function useLocalFiles() {
|
||||||
const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan } = useDeviceFiles();
|
const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders } = useDeviceFiles();
|
||||||
|
|
||||||
const registryEntries = useMemo(() => localFileRegistry.getAll(), []);
|
const registryEntries = useMemo(() => localFileRegistry.getAll(), []);
|
||||||
|
|
||||||
@@ -40,5 +40,8 @@ export function useLocalFiles() {
|
|||||||
hasPermission,
|
hasPermission,
|
||||||
requestPermission,
|
requestPermission,
|
||||||
rescan,
|
rescan,
|
||||||
|
pickDirectory,
|
||||||
|
folders,
|
||||||
|
refreshFolders,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function getExtension(name: string): string {
|
|||||||
|
|
||||||
export function useUnifiedFiles(page: number = 1, limit: number = 50) {
|
export function useUnifiedFiles(page: number = 1, limit: number = 50) {
|
||||||
const { data: backendData, isLoading: backendLoading, error: backendError } = useFiles(page, limit);
|
const { data: backendData, isLoading: backendLoading, error: backendError } = useFiles(page, limit);
|
||||||
const { localFiles, isLoading: localLoading, hasPermission, requestPermission } = useLocalFiles();
|
const { localFiles, isLoading: localLoading, hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useLocalFiles();
|
||||||
|
|
||||||
const unifiedFiles = useMemo(() => {
|
const unifiedFiles = useMemo(() => {
|
||||||
const backendFiles = backendData?.data ?? [];
|
const backendFiles = backendData?.data ?? [];
|
||||||
@@ -110,6 +110,9 @@ export function useUnifiedFiles(page: number = 1, limit: number = 50) {
|
|||||||
error: backendError,
|
error: backendError,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
requestPermission,
|
requestPermission,
|
||||||
|
pickDirectory,
|
||||||
|
folders,
|
||||||
|
refreshFolders,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.library'
|
||||||
|
id 'expo-module-gradle-plugin'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'host.exp.exponent'
|
||||||
|
version = '0.1.0'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "expo.modules.downloaddetect"
|
||||||
|
defaultConfig {
|
||||||
|
versionCode 1
|
||||||
|
versionName "0.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<manifest>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package expo.modules.downloaddetect
|
||||||
|
|
||||||
|
import android.app.DownloadManager
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
data class DetectedFile(
|
||||||
|
val id: String,
|
||||||
|
val uri: String,
|
||||||
|
val name: String,
|
||||||
|
val mimeType: String,
|
||||||
|
val size: Long,
|
||||||
|
val createdAt: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
class DownloadBroadcastReceiver(
|
||||||
|
private val onFileDetected: (DetectedFile) -> Unit
|
||||||
|
) : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE) return
|
||||||
|
|
||||||
|
val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
|
||||||
|
if (downloadId == -1L) return
|
||||||
|
|
||||||
|
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||||
|
val query = DownloadManager.Query().setFilterById(downloadId)
|
||||||
|
|
||||||
|
dm.query(query)?.use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) return
|
||||||
|
|
||||||
|
val uriIndex = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
|
||||||
|
val titleIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TITLE)
|
||||||
|
val mimeTypeIndex = cursor.getColumnIndex(DownloadManager.COLUMN_MEDIA_TYPE)
|
||||||
|
val sizeIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||||
|
val dateIndex = cursor.getColumnIndex(DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP)
|
||||||
|
|
||||||
|
val uri = if (uriIndex >= 0) cursor.getString(uriIndex) ?: "" else ""
|
||||||
|
val title = if (titleIndex >= 0) cursor.getString(titleIndex) ?: "unknown" else "unknown"
|
||||||
|
val mimeType = if (mimeTypeIndex >= 0) cursor.getString(mimeTypeIndex) ?: "application/octet-stream" else "application/octet-stream"
|
||||||
|
val size = if (sizeIndex >= 0) cursor.getLong(sizeIndex) else 0L
|
||||||
|
val date = if (dateIndex >= 0) cursor.getLong(dateIndex) else System.currentTimeMillis()
|
||||||
|
|
||||||
|
// Extract filename from URI
|
||||||
|
val name = extractFileName(uri, title)
|
||||||
|
|
||||||
|
onFileDetected(DetectedFile(
|
||||||
|
id = "download_$downloadId",
|
||||||
|
uri = uri,
|
||||||
|
name = name,
|
||||||
|
mimeType = mimeType,
|
||||||
|
size = size,
|
||||||
|
createdAt = date
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractFileName(uri: String, fallback: String): String {
|
||||||
|
if (uri.isNotEmpty()) {
|
||||||
|
// content://media/external/downloads/123 or file:///storage/...
|
||||||
|
val path = uri.substringAfterLast("/")
|
||||||
|
if (path.isNotEmpty() && path.all { it.isDigit() }.not()) {
|
||||||
|
return decodeFileName(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeFileName(encoded: String): String {
|
||||||
|
return try {
|
||||||
|
java.net.URLDecoder.decode(encoded, "UTF-8")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
package expo.modules.downloaddetect
|
||||||
|
|
||||||
|
import android.app.DownloadManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.core.os.bundleOf
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
|
class ExpoDownloadDetectModule : Module() {
|
||||||
|
|
||||||
|
private var broadcastReceiver: DownloadBroadcastReceiver? = null
|
||||||
|
private var contentObserver: MediaStoreObserver? = null
|
||||||
|
private var isWatching = false
|
||||||
|
|
||||||
|
override fun definition() = ModuleDefinition {
|
||||||
|
Name("ExpoDownloadDetect")
|
||||||
|
|
||||||
|
Events("onNewFile")
|
||||||
|
|
||||||
|
Function("startWatching") {
|
||||||
|
if (!isWatching) startObserving()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("stopWatching") {
|
||||||
|
stopObserving()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getRecentDownloads") {
|
||||||
|
queryRecentFiles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val context: Context
|
||||||
|
get() = requireNotNull(appContext.reactContext)
|
||||||
|
|
||||||
|
private fun startObserving() {
|
||||||
|
val ctx = context
|
||||||
|
|
||||||
|
// Register BroadcastReceiver for DownloadManager
|
||||||
|
broadcastReceiver = DownloadBroadcastReceiver { event ->
|
||||||
|
sendEvent("onNewFile", bundleOf(
|
||||||
|
"id" to event.id,
|
||||||
|
"uri" to event.uri,
|
||||||
|
"name" to event.name,
|
||||||
|
"mimeType" to event.mimeType,
|
||||||
|
"size" to event.size,
|
||||||
|
"createdAt" to event.createdAt,
|
||||||
|
"source" to "download"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
val intentFilter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
||||||
|
ctx.registerReceiver(broadcastReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED)
|
||||||
|
|
||||||
|
// Register ContentObserver on MediaStore
|
||||||
|
val handler = Handler(Looper.getMainLooper())
|
||||||
|
contentObserver = MediaStoreObserver(handler, ctx) { newFiles ->
|
||||||
|
for (file in newFiles) {
|
||||||
|
sendEvent("onNewFile", bundleOf(
|
||||||
|
"id" to file.id,
|
||||||
|
"uri" to file.uri,
|
||||||
|
"name" to file.name,
|
||||||
|
"mimeType" to file.mimeType,
|
||||||
|
"size" to file.size,
|
||||||
|
"createdAt" to file.createdAt,
|
||||||
|
"source" to "mediastore"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = MediaStore.Files.getContentUri("external")
|
||||||
|
ctx.contentResolver.registerContentObserver(uri, true, contentObserver!!)
|
||||||
|
|
||||||
|
// Scan existing files on startup
|
||||||
|
val existingFiles = queryRecentFiles()
|
||||||
|
for (file in existingFiles) {
|
||||||
|
sendEvent("onNewFile", bundleOf(
|
||||||
|
"id" to file.id,
|
||||||
|
"uri" to file.uri,
|
||||||
|
"name" to file.name,
|
||||||
|
"mimeType" to file.mimeType,
|
||||||
|
"size" to file.size,
|
||||||
|
"createdAt" to file.createdAt,
|
||||||
|
"source" to "startup"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
isWatching = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopObserving() {
|
||||||
|
val ctx = context
|
||||||
|
|
||||||
|
broadcastReceiver?.let {
|
||||||
|
try {
|
||||||
|
ctx.unregisterReceiver(it)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
broadcastReceiver = null
|
||||||
|
}
|
||||||
|
|
||||||
|
contentObserver?.let {
|
||||||
|
ctx.contentResolver.unregisterContentObserver(it)
|
||||||
|
contentObserver = null
|
||||||
|
}
|
||||||
|
|
||||||
|
isWatching = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryRecentFiles(): List<DetectedFile> {
|
||||||
|
val files = mutableListOf<DetectedFile>()
|
||||||
|
val projection = arrayOf(
|
||||||
|
MediaStore.Files.FileColumns._ID,
|
||||||
|
MediaStore.Files.FileColumns.DISPLAY_NAME,
|
||||||
|
MediaStore.Files.FileColumns.MIME_TYPE,
|
||||||
|
MediaStore.Files.FileColumns.SIZE,
|
||||||
|
MediaStore.Files.FileColumns.DATE_ADDED
|
||||||
|
)
|
||||||
|
|
||||||
|
val selection = "${MediaStore.Files.FileColumns.SIZE} > 0"
|
||||||
|
val sortOrder = "${MediaStore.Files.FileColumns.DATE_ADDED} DESC"
|
||||||
|
val limit = 100
|
||||||
|
|
||||||
|
context.contentResolver.query(
|
||||||
|
MediaStore.Files.getContentUri("external"),
|
||||||
|
projection,
|
||||||
|
selection,
|
||||||
|
null,
|
||||||
|
sortOrder
|
||||||
|
)?.use { cursor ->
|
||||||
|
val idCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
|
||||||
|
val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME)
|
||||||
|
val mimeCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MIME_TYPE)
|
||||||
|
val sizeCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.SIZE)
|
||||||
|
val dateCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_ADDED)
|
||||||
|
|
||||||
|
var count = 0
|
||||||
|
while (cursor.moveToNext() && count < limit) {
|
||||||
|
val id = cursor.getLong(idCol)
|
||||||
|
val name = cursor.getString(nameCol) ?: continue
|
||||||
|
val mimeType = cursor.getString(mimeCol) ?: "application/octet-stream"
|
||||||
|
val size = cursor.getLong(sizeCol)
|
||||||
|
val dateAdded = cursor.getLong(dateCol)
|
||||||
|
|
||||||
|
val uri = Uri.withAppendedPath(
|
||||||
|
MediaStore.Files.getContentUri("external"),
|
||||||
|
id.toString()
|
||||||
|
).toString()
|
||||||
|
|
||||||
|
files.add(DetectedFile(
|
||||||
|
id = "media_$id",
|
||||||
|
uri = uri,
|
||||||
|
name = name,
|
||||||
|
mimeType = mimeType,
|
||||||
|
size = size,
|
||||||
|
createdAt = dateAdded * 1000L
|
||||||
|
))
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
package expo.modules.downloaddetect
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.ContentObserver
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.provider.MediaStore
|
||||||
|
|
||||||
|
class MediaStoreObserver(
|
||||||
|
handler: Handler,
|
||||||
|
private val context: Context,
|
||||||
|
private val onNewFiles: (List<DetectedFile>) -> Unit
|
||||||
|
) : ContentObserver(handler) {
|
||||||
|
|
||||||
|
private var lastScanTimestamp: Long = System.currentTimeMillis() / 1000L
|
||||||
|
|
||||||
|
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||||
|
super.onChange(selfChange, uri)
|
||||||
|
queryNewFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onChange(selfChange: Boolean, uri: Uri?, flags: Int) {
|
||||||
|
super.onChange(selfChange, uri, flags)
|
||||||
|
queryNewFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryNewFiles() {
|
||||||
|
val files = mutableListOf<DetectedFile>()
|
||||||
|
val projection = arrayOf(
|
||||||
|
MediaStore.Files.FileColumns._ID,
|
||||||
|
MediaStore.Files.FileColumns.DISPLAY_NAME,
|
||||||
|
MediaStore.Files.FileColumns.MIME_TYPE,
|
||||||
|
MediaStore.Files.FileColumns.SIZE,
|
||||||
|
MediaStore.Files.FileColumns.DATE_ADDED
|
||||||
|
)
|
||||||
|
|
||||||
|
val selection = "${MediaStore.Files.FileColumns.DATE_ADDED} > ? AND ${MediaStore.Files.FileColumns.SIZE} > 0"
|
||||||
|
val selectionArgs = arrayOf(lastScanTimestamp.toString())
|
||||||
|
val sortOrder = "${MediaStore.Files.FileColumns.DATE_ADDED} DESC"
|
||||||
|
|
||||||
|
val resolver: ContentResolver = context.contentResolver
|
||||||
|
var cursor: Cursor? = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try MediaStore.Downloads first
|
||||||
|
cursor = try {
|
||||||
|
resolver.query(
|
||||||
|
MediaStore.Downloads.getContentUri("external"),
|
||||||
|
projection, selection, selectionArgs, sortOrder
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to MediaStore.Files if Downloads didn't work
|
||||||
|
if (cursor == null || cursor.count == 0) {
|
||||||
|
cursor?.close()
|
||||||
|
cursor = resolver.query(
|
||||||
|
MediaStore.Files.getContentUri("external"),
|
||||||
|
projection, selection, selectionArgs, sortOrder
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor?.use { c ->
|
||||||
|
val idCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
|
||||||
|
val nameCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME)
|
||||||
|
val mimeCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MIME_TYPE)
|
||||||
|
val sizeCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.SIZE)
|
||||||
|
val dateCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_ADDED)
|
||||||
|
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val id = c.getLong(idCol)
|
||||||
|
val name = c.getString(nameCol) ?: continue
|
||||||
|
val mimeType = c.getString(mimeCol) ?: "application/octet-stream"
|
||||||
|
val size = c.getLong(sizeCol)
|
||||||
|
val dateAdded = c.getLong(dateCol)
|
||||||
|
|
||||||
|
val fileUri = Uri.withAppendedPath(
|
||||||
|
MediaStore.Files.getContentUri("external"),
|
||||||
|
id.toString()
|
||||||
|
).toString()
|
||||||
|
|
||||||
|
files.add(DetectedFile(
|
||||||
|
id = "media_$id",
|
||||||
|
uri = fileUri,
|
||||||
|
name = name,
|
||||||
|
mimeType = mimeType,
|
||||||
|
size = size,
|
||||||
|
createdAt = dateAdded * 1000L
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
} finally {
|
||||||
|
cursor?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.isNotEmpty()) {
|
||||||
|
lastScanTimestamp = System.currentTimeMillis() / 1000L
|
||||||
|
onNewFiles(files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["android"],
|
||||||
|
"android": {
|
||||||
|
"modules": ["expo.modules.downloaddetect.ExpoDownloadDetectModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { ExpoDownloadDetectModule } from './src';
|
||||||
|
export type { FileDetectedEvent } from './src';
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "expo-download-detect",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Detect new files via DownloadManager and MediaStore ContentObserver",
|
||||||
|
"main": "index.ts",
|
||||||
|
"android": {
|
||||||
|
"sourceDir": "android"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": ">=57.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export type FileDetectedEvent = {
|
||||||
|
id: string;
|
||||||
|
uri: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
createdAt: number;
|
||||||
|
source: 'download' | 'mediastore' | 'startup';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExpoDownloadDetectModuleEvents = {
|
||||||
|
onNewFile: (event: FileDetectedEvent) => void;
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { NativeModule, requireNativeModule } from 'expo';
|
||||||
|
import { ExpoDownloadDetectModuleEvents, FileDetectedEvent } from './ExpoDownloadDetect.types';
|
||||||
|
|
||||||
|
declare class ExpoDownloadDetectModule extends NativeModule<ExpoDownloadDetectModuleEvents> {
|
||||||
|
startWatching(): void;
|
||||||
|
stopWatching(): void;
|
||||||
|
getRecentDownloads(): Promise<FileDetectedEvent[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default requireNativeModule<ExpoDownloadDetectModule>('ExpoDownloadDetect');
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as ExpoDownloadDetectModule } from './ExpoDownloadDetectModule';
|
||||||
|
export type { FileDetectedEvent, ExpoDownloadDetectModuleEvents } from './ExpoDownloadDetect.types';
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { createMMKV } from 'react-native-mmkv';
|
||||||
|
import { FileDetectedEvent } from '../modules/expo-download-detect';
|
||||||
|
|
||||||
|
const storage = createMMKV({ id: 'vaultdrop-download-registry' });
|
||||||
|
|
||||||
|
const FILES_KEY = 'detected_files';
|
||||||
|
|
||||||
|
export type DownloadRegistryEntry = {
|
||||||
|
id: string;
|
||||||
|
uri: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
createdAt: number;
|
||||||
|
source: string;
|
||||||
|
detectedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getAllRaw(): DownloadRegistryEntry[] {
|
||||||
|
const raw = storage.getString(FILES_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
return JSON.parse(raw) as DownloadRegistryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAll(entries: DownloadRegistryEntry[]) {
|
||||||
|
storage.set(FILES_KEY, JSON.stringify(entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const downloadRegistry = {
|
||||||
|
getAll(): DownloadRegistryEntry[] {
|
||||||
|
return getAllRaw();
|
||||||
|
},
|
||||||
|
|
||||||
|
getById(id: string): DownloadRegistryEntry | undefined {
|
||||||
|
return getAllRaw().find((e) => e.id === id);
|
||||||
|
},
|
||||||
|
|
||||||
|
add(event: FileDetectedEvent): DownloadRegistryEntry | null {
|
||||||
|
const entries = getAllRaw();
|
||||||
|
if (entries.some((e) => e.id === event.id)) return null;
|
||||||
|
|
||||||
|
const entry: DownloadRegistryEntry = {
|
||||||
|
id: event.id,
|
||||||
|
uri: event.uri,
|
||||||
|
name: event.name,
|
||||||
|
mimeType: event.mimeType,
|
||||||
|
size: event.size,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
source: event.source,
|
||||||
|
detectedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
entries.push(entry);
|
||||||
|
saveAll(entries);
|
||||||
|
return entry;
|
||||||
|
},
|
||||||
|
|
||||||
|
addBatch(events: FileDetectedEvent[]): DownloadRegistryEntry[] {
|
||||||
|
const entries = getAllRaw();
|
||||||
|
const existingIds = new Set(entries.map((e) => e.id));
|
||||||
|
const newEntries: DownloadRegistryEntry[] = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (existingIds.has(event.id)) continue;
|
||||||
|
const entry: DownloadRegistryEntry = {
|
||||||
|
id: event.id,
|
||||||
|
uri: event.uri,
|
||||||
|
name: event.name,
|
||||||
|
mimeType: event.mimeType,
|
||||||
|
size: event.size,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
source: event.source,
|
||||||
|
detectedAt: Date.now(),
|
||||||
|
};
|
||||||
|
newEntries.push(entry);
|
||||||
|
existingIds.add(event.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newEntries.length > 0) {
|
||||||
|
saveAll([...entries, ...newEntries]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newEntries;
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(id: string) {
|
||||||
|
const entries = getAllRaw().filter((e) => e.id !== id);
|
||||||
|
saveAll(entries);
|
||||||
|
},
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
storage.remove(FILES_KEY);
|
||||||
|
},
|
||||||
|
|
||||||
|
count(): number {
|
||||||
|
return getAllRaw().length;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { createMMKV } from 'react-native-mmkv';
|
||||||
|
import { ONBOARDING_STEPS, CURRENT_ONBOARDING_VERSION, type OnboardingStep } from '../config/onboarding';
|
||||||
|
import { safDirectory } from './safDirectory';
|
||||||
|
|
||||||
|
const storage = createMMKV({ id: 'vaultdrop-onboarding' });
|
||||||
|
|
||||||
|
const COMPLETED_VERSION_KEY = 'completed_version';
|
||||||
|
const SEEN_STEPS_KEY = 'seen_steps';
|
||||||
|
|
||||||
|
export const onboardingStorage = {
|
||||||
|
getCompletedVersion(): number | undefined {
|
||||||
|
const raw = storage.getNumber(COMPLETED_VERSION_KEY);
|
||||||
|
return raw != null ? raw : undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
setCompletedVersion(version: number) {
|
||||||
|
storage.set(COMPLETED_VERSION_KEY, version);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSeenSteps(): string[] {
|
||||||
|
const raw = storage.getString(SEEN_STEPS_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
return JSON.parse(raw) as string[];
|
||||||
|
},
|
||||||
|
|
||||||
|
markStepSeen(stepId: string) {
|
||||||
|
const seen = this.getSeenSteps();
|
||||||
|
if (!seen.includes(stepId)) {
|
||||||
|
seen.push(stepId);
|
||||||
|
storage.set(SEEN_STEPS_KEY, JSON.stringify(seen));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getPendingSteps(): OnboardingStep[] {
|
||||||
|
const lastVersion = this.getCompletedVersion();
|
||||||
|
const seenIds = this.getSeenSteps();
|
||||||
|
const folders = safDirectory.getAll();
|
||||||
|
|
||||||
|
return ONBOARDING_STEPS.filter((step) => {
|
||||||
|
if (lastVersion != null && step.version <= lastVersion) return false;
|
||||||
|
if (seenIds.includes(step.id)) return false;
|
||||||
|
if (step.condition === 'has_no_folders' && folders.length > 0) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
needsOnboarding(): boolean {
|
||||||
|
return this.getPendingSteps().length > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
storage.remove(COMPLETED_VERSION_KEY);
|
||||||
|
storage.remove(SEEN_STEPS_KEY);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { createMMKV } from 'react-native-mmkv';
|
||||||
|
|
||||||
|
export type StoredFolder = {
|
||||||
|
id: string;
|
||||||
|
uri: string;
|
||||||
|
name: string;
|
||||||
|
visible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const storage = createMMKV({ id: 'vaultdrop-saf' });
|
||||||
|
|
||||||
|
const FOLDERS_KEY = 'saf_folders';
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return `folder_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllRaw(): StoredFolder[] {
|
||||||
|
const raw = storage.getString(FOLDERS_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
return JSON.parse(raw) as StoredFolder[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAll(folders: StoredFolder[]) {
|
||||||
|
storage.set(FOLDERS_KEY, JSON.stringify(folders));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const safDirectory = {
|
||||||
|
getAll(): StoredFolder[] {
|
||||||
|
return getAllRaw();
|
||||||
|
},
|
||||||
|
|
||||||
|
getVisibleFolders(): StoredFolder[] {
|
||||||
|
return getAllRaw().filter((f) => f.visible);
|
||||||
|
},
|
||||||
|
|
||||||
|
addFolder(uri: string, name: string): StoredFolder {
|
||||||
|
const folders = getAllRaw();
|
||||||
|
if (folders.some((f) => f.uri === uri)) {
|
||||||
|
return folders.find((f) => f.uri === uri)!;
|
||||||
|
}
|
||||||
|
const folder: StoredFolder = { id: generateId(), uri, name, visible: true };
|
||||||
|
folders.push(folder);
|
||||||
|
saveAll(folders);
|
||||||
|
return folder;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFolder(id: string) {
|
||||||
|
const folders = getAllRaw().filter((f) => f.id !== id);
|
||||||
|
saveAll(folders);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleVisibility(id: string) {
|
||||||
|
const folders = getAllRaw().map((f) =>
|
||||||
|
f.id === id ? { ...f, visible: !f.visible } : f
|
||||||
|
);
|
||||||
|
saveAll(folders);
|
||||||
|
},
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
storage.remove(FOLDERS_KEY);
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user