import { Button, ButtonVariant } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { CORNERS, FONT_SIZE } from '@/theme/globals'; import * as DocumentPicker from 'expo-document-picker'; import { File, Image, X } from 'lucide-react-native'; import React, { forwardRef, useCallback, useMemo, useState } from 'react'; import { ScrollView, StyleSheet, TouchableOpacity, ViewStyle, } from 'react-native'; export type FileType = 'image' | 'document' | 'all'; export interface SelectedFile { uri: string; name: string; type?: string; size?: number; mimeType?: string; } export interface FilePickerProps { // Core functionality onFilesSelected: (files: SelectedFile[]) => void; onError?: (error: string) => void; // Configuration fileType?: FileType; multiple?: boolean; maxFiles?: number; maxSizeBytes?: number; allowedExtensions?: string[]; // UI customization placeholder?: string; disabled?: boolean; style?: ViewStyle; showPreview?: boolean; showFileInfo?: boolean; // Accessibility accessibilityLabel?: string; accessibilityHint?: string; variant?: ButtonVariant; } interface FilePickerMethods { clearFiles: () => void; openPicker: () => void; } export const FilePicker = forwardRef( ( { onFilesSelected, onError, fileType = 'all', multiple = false, maxFiles = 10, maxSizeBytes = 10 * 1024 * 1024, // 10MB default allowedExtensions, placeholder = 'Select files', disabled = false, style = {}, showFileInfo = true, accessibilityLabel, accessibilityHint, variant = 'outline', }, ref ) => { const [selectedFiles, setSelectedFiles] = useState([]); // Theme colors const backgroundColor = useColor('card'); const borderColor = useColor('border'); const textColor = useColor('text'); const mutedTextColor = useColor('textMuted'); const primaryColor = useColor('primary'); // Expose methods via ref React.useImperativeHandle(ref, () => ({ clearFiles: () => { setSelectedFiles([]); onFilesSelected([]); }, openPicker: () => { handleDocumentPick(); }, })); const validateFile = useCallback( (file: SelectedFile): string | null => { // Size validation if (file.size && file.size > maxSizeBytes) { return `File size exceeds ${(maxSizeBytes / (1024 * 1024)).toFixed( 1 )}MB limit`; } // Extension validation if (allowedExtensions && allowedExtensions.length > 0) { const extension = file.name.split('.').pop()?.toLowerCase(); if (!extension || !allowedExtensions.includes(extension)) { return `File type not allowed. Allowed types: ${allowedExtensions.join( ', ' )}`; } } return null; }, [maxSizeBytes, allowedExtensions] ); const addFiles = useCallback( (newFiles: SelectedFile[]) => { const validFiles: SelectedFile[] = []; const errors: string[] = []; for (const file of newFiles) { const error = validateFile(file); if (error) { errors.push(`${file.name}: ${error}`); } else { validFiles.push(file); } } if (errors.length > 0) { onError?.(errors.join('\n')); } if (validFiles.length > 0) { const updatedFiles = multiple ? [...selectedFiles, ...validFiles].slice(0, maxFiles) : validFiles.slice(0, 1); setSelectedFiles(updatedFiles); onFilesSelected(updatedFiles); if (multiple && selectedFiles.length + validFiles.length > maxFiles) { onError?.(`Only first ${maxFiles} files were selected`); } } }, [ selectedFiles, multiple, maxFiles, validateFile, onFilesSelected, onError, ] ); const removeFile = useCallback( (index: number) => { const updatedFiles = selectedFiles.filter((_, i) => i !== index); setSelectedFiles(updatedFiles); onFilesSelected(updatedFiles); }, [selectedFiles, onFilesSelected] ); const handleDocumentPick = useCallback(async () => { try { const result = await DocumentPicker.getDocumentAsync({ type: fileType === 'image' ? 'image/*' : '*/*', multiple, copyToCacheDirectory: true, }); if (!result.canceled) { const files: SelectedFile[] = result.assets.map((asset) => ({ uri: asset.uri, name: asset.name, size: asset.size, mimeType: asset.mimeType || undefined, })); addFiles(files); } } catch (error) { onError?.(`Failed to pick document: ${error}`); } }, [fileType, multiple, addFiles, onError]); const handlePickerPress = useCallback(() => { if (disabled) return; handleDocumentPick(); }, [disabled, fileType, handleDocumentPick]); const formatFileSize = (bytes: number): string => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; const getFileIcon = (fileName: string) => { const extension = fileName.split('.').pop()?.toLowerCase(); if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(extension || '')) { return ; } return ; }; return ( {/* File Picker Button */} {/* Selected Files Preview */} {selectedFiles.length > 0 && ( {selectedFiles.map((file, index) => ( {getFileIcon(file.name)} {file.name} {showFileInfo && file.size && ( {formatFileSize(file.size)} )} removeFile(index)} style={styles.removeButton} accessibilityLabel={`Remove ${file.name}`} > ))} )} ); } ); FilePicker.displayName = 'FilePicker'; const styles = StyleSheet.create({ container: { width: '100%', }, pickerButton: { justifyContent: 'flex-start', paddingHorizontal: 16, minHeight: 48, }, buttonContent: { flexDirection: 'row', alignItems: 'center', gap: 12, }, buttonText: { fontSize: FONT_SIZE, fontWeight: '400', }, filesContainer: { marginTop: 12, maxHeight: 300, }, fileItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 12, borderRadius: CORNERS, borderWidth: 1, marginBottom: 8, }, fileInfo: { flexDirection: 'row', alignItems: 'center', flex: 1, gap: 12, }, fileDetails: { flex: 1, }, fileName: { fontSize: FONT_SIZE, fontWeight: '500', }, fileSize: { fontSize: 14, marginTop: 2, }, removeButton: { padding: 4, }, }); // Export utility functions for external use export const createFileFromUri = async ( uri: string, name?: string ): Promise => { return { uri, name: name || uri.split('/').pop() || 'file', }; }; export const validateFiles = ( files: SelectedFile[], options: { maxSize?: number; allowedExtensions?: string[]; maxFiles?: number; } ): { valid: SelectedFile[]; errors: string[] } => { const valid: SelectedFile[] = []; const errors: string[] = []; for (const file of files) { if (options.maxSize && file.size && file.size > options.maxSize) { errors.push(`${file.name}: File too large`); continue; } if (options.allowedExtensions) { const ext = file.name.split('.').pop()?.toLowerCase(); if (!ext || !options.allowedExtensions.includes(ext)) { errors.push(`${file.name}: File type not allowed`); continue; } } valid.push(file); } if (options.maxFiles && valid.length > options.maxFiles) { valid.splice(options.maxFiles); errors.push(`Only first ${options.maxFiles} files selected`); } return { valid, errors }; }; export interface UseFilePickerOptions { maxFiles?: number; maxSizeBytes?: number; allowedExtensions?: string[]; onError?: (error: string) => void; } export interface UseFilePickerReturn { files: SelectedFile[]; addFiles: (newFiles: SelectedFile[]) => void; removeFile: (index: number) => void; clearFiles: () => void; totalSize: number; isValid: boolean; errors: string[]; } export function useFilePicker( options: UseFilePickerOptions = {} ): UseFilePickerReturn { const { maxFiles = 10, maxSizeBytes = 10 * 1024 * 1024, // 10MB default allowedExtensions, onError, } = options; const [files, setFiles] = useState([]); const [errors, setErrors] = useState([]); const validateFile = useCallback( (file: SelectedFile): string | null => { // Check file size if (file.size && file.size > maxSizeBytes) { return `File size exceeds ${(maxSizeBytes / (1024 * 1024)).toFixed( 1 )}MB limit`; } // Check file extension if (allowedExtensions && allowedExtensions.length > 0) { const extension = file.name.split('.').pop()?.toLowerCase(); if (!extension || !allowedExtensions.includes(extension)) { return `File type not allowed. Allowed types: ${allowedExtensions.join( ', ' )}`; } } return null; }, [maxSizeBytes, allowedExtensions] ); const addFiles = useCallback( (newFiles: SelectedFile[]) => { const validFiles: SelectedFile[] = []; const validationErrors: string[] = []; // Validate each file for (const file of newFiles) { const error = validateFile(file); if (error) { validationErrors.push(`${file.name}: ${error}`); } else { validFiles.push(file); } } // Handle validation errors if (validationErrors.length > 0) { setErrors(validationErrors); onError?.(validationErrors.join('\n')); } else { setErrors([]); } // Add valid files if (validFiles.length > 0) { setFiles((prev) => { const combined = [...prev, ...validFiles]; // Check if exceeds max files limit if (combined.length > maxFiles) { const truncated = combined.slice(0, maxFiles); const truncationError = `Only first ${maxFiles} files were selected`; setErrors((prev) => [...prev, truncationError]); onError?.(truncationError); return truncated; } return combined; }); } }, [validateFile, maxFiles, onError] ); const removeFile = useCallback((index: number) => { setFiles((prev) => prev.filter((_, i) => i !== index)); // Clear errors when files are removed setErrors([]); }, []); const clearFiles = useCallback(() => { setFiles([]); setErrors([]); }, []); // Calculate total size of all files const totalSize = useMemo(() => { return files.reduce((sum, file) => sum + (file.size || 0), 0); }, [files]); // Check if current state is valid const isValid = useMemo(() => { return errors.length === 0 && files.length > 0 && files.length <= maxFiles; }, [errors.length, files.length, maxFiles]); return { files, addFiles, removeFile, clearFiles, totalSize, isValid, errors, }; }