import React, { useState, useCallback, useEffect, createContext, useContext } from 'react';
import { View, Text, Alert } from 'react-native';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import * as DocumentPicker from 'expo-document-picker';
import { AntDesign } from "@react-native-vector-icons/ant-design";
export interface FileWithPreview {
id: string;
file: DocumentPicker.DocumentPickerAsset;
name: string;
size: number;
type: string;
progress: number;
status: 'uploading' | 'complete' | 'error';
error?: string | null;
preview?: string | null;
}
interface FileUploaderContextType {
files: FileWithPreview[];
maxFiles: number;
maxSize: number;
accept: string[];
onFilesReady?: (files: DocumentPicker.DocumentPickerAsset[]) => void;
addFiles: (files: DocumentPicker.DocumentPickerAsset[]) => void;
removeFile: (fileId: string) => void;
clearAllFiles: () => void;
updateFile: (fileId: string, updates: Partial<FileWithPreview>) => void;
formatFileSize: (bytes: number) => string;
validateFile: (file: DocumentPicker.DocumentPickerAsset) => string | null;
getFileIcon: (file: DocumentPicker.DocumentPickerAsset) => React.ReactNode;
}
const FileUploaderContext = createContext<FileUploaderContextType | null>(null);
const useFileUploader = () => {
const context = useContext(FileUploaderContext);
if (!context) {
throw new Error('FileUploader components must be used within a FileUploader');
}
return context;
};
const FileUploaderProgress = React.forwardRef<
View,
React.ComponentProps<typeof View> & { value?: number }
>(({ className, value = 0, ...props }, ref) => (
<View
ref={ref}
className={cn("relative w-full overflow-hidden rounded-full bg-secondary h-1", className)}
{...props}
>
<View
className="h-full bg-primary transition-all duration-300 ease-in-out"
style={{ width: `${value}%` }}
/>
</View>
));
FileUploaderProgress.displayName = "FileUploaderProgress";
interface FileUploaderPreviewProps {
file: FileWithPreview;
className?: string;
}
function FileUploaderPreview({ file, className }: FileUploaderPreviewProps) {
const { getFileIcon } = useFileUploader();
return (
<View className={cn("w-12 h-12 bg-muted rounded-md flex items-center justify-center shrink-0 border", className)}>
{file.error ? (
<AntDesign name="exclamation-circle" size={24} />
) : (
getFileIcon(file.file)
)}
</View>
);
}
interface FileUploaderDropZoneProps {
className?: string;
disabled?: boolean;
}
function FileUploaderDropZone({ className, disabled }: FileUploaderDropZoneProps) {
const {
files,
maxFiles,
maxSize,
accept,
addFiles,
formatFileSize
} = useFileUploader();
const handlePickDocument = async () => {
try {
const result = await DocumentPicker.getDocumentAsync({
type: accept.length > 0 ? accept : '*/*',
multiple: true,
copyToCacheDirectory: true,
});
if (!result.canceled) {
addFiles(result.assets);
}
} catch (error) {
Alert.alert('Error', 'Failed to pick files');
}
};
const getReadableFileTypes = useCallback(() => {
return accept.map(type => {
if (type === 'image/*') return 'Images';
if (type === 'application/pdf') return 'PDF';
if (type === 'text/*') return 'Text files';
if (type === 'video/*') return 'Videos';
if (type === 'audio/*') return 'Audio';
return type;
});
}, [accept]);
const isDisabled = disabled || files.length >= maxFiles;
return (
<Card
className={cn(
"relative border-2 border-dashed border-muted",
isDisabled && "opacity-50",
className
)}
>
<CardContent className="flex flex-col items-center justify-center p-8">
<View className="flex items-center justify-center w-16 h-16 rounded-full mb-4 bg-muted/50">
<AntDesign name='cloud-upload' size={32} />
</View>
<Text className="text-lg font-semibold mb-2 text-foreground">
Upload Files
</Text>
<Text className="text-sm text-muted-foreground mb-4 text-center">
Tap to browse and select files
</Text>
<View className="flex flex-row flex-wrap gap-2 mb-4 justify-center">
{getReadableFileTypes().map((type, index) => (
<Badge key={index} variant="secondary">
<Text className='text-xs'>
{type}
</Text>
</Badge>
))}
</View>
<Button
onPress={handlePickDocument}
variant="outline"
disabled={isDisabled}
>
<Text>
Choose Files
</Text>
</Button>
<Text className="text-xs text-muted-foreground mt-2 text-center">
Max {maxFiles} files, up to {formatFileSize(maxSize)} each
</Text>
</CardContent>
</Card>
);
}
interface FileItemProps {
file: FileWithPreview;
}
function FileItem({ file }: FileItemProps) {
const { removeFile, formatFileSize } = useFileUploader();
return (
<Card className="relative overflow-hidden">
<View className={cn(
"absolute inset-0 opacity-0",
file.error ? "bg-destructive/5" : "bg-primary/5"
)} />
<CardContent className="p-4 relative">
<View className="flex flex-row items-center gap-3">
<FileUploaderPreview file={file} />
<View className="flex-1 min-w-0">
<View className="flex flex-row items-center gap-2 mb-1">
<Text className="text-sm font-medium text-foreground flex-1" numberOfLines={1}>
{file.name}
</Text>
{file.status === 'complete' && !file.error && (
<AntDesign name='check' size={16} />
)}
</View>
<Text className="text-xs text-muted-foreground mb-2">
{formatFileSize(file.size)} • {file.type}
</Text>
{file.error ? (
<View className="flex flex-row items-center gap-1">
<AntDesign name="exclamation-circle" size={12} />
<Text className="text-xs text-destructive">{file.error}</Text>
</View>
) : (
<FileUploaderProgress
value={file.progress}
className="h-1 rounded-full bg-secondary"
/>
)}
</View>
<Button
variant="ghost"
size="icon"
onPress={() => removeFile(file.id)}
className="shrink-0 h-8 w-8 rounded-full opacity-70"
>
<AntDesign name='close' size={16} />
</Button>
</View>
</CardContent>
</Card>
);
}
interface FileUploaderFileListProps {
className?: string;
showHeader?: boolean;
}
function FileUploaderFileList({
className,
showHeader = true,
}: FileUploaderFileListProps) {
const {
files,
maxFiles,
clearAllFiles
} = useFileUploader();
if (files.length === 0) {
return null;
}
return (
<View className={cn("gap-3", className)}>
{showHeader && (
<View className="flex flex-row items-center justify-between">
<Text className="text-sm font-medium text-foreground">
Uploaded Files ({files.length}/{maxFiles})
</Text>
<Button
variant="ghost"
size="sm"
onPress={clearAllFiles}
className="text-xs"
>
<Text>
Clear All
</Text>
</Button>
</View>
)}
{files.map((fileData) => (
<FileItem
key={fileData.id}
file={fileData}
/>
))}
</View>
);
}
export interface FileUploaderProps {
onFilesReady?: (files: DocumentPicker.DocumentPickerAsset[]) => void;
maxFiles?: number;
maxSize?: number;
accept?: string[];
className?: string;
children?: React.ReactNode;
}
export function FileUploader({
onFilesReady,
maxFiles = 10,
maxSize = 10 * 1024 * 1024,
accept = ['image/*', 'application/pdf', 'text/*'],
className,
children
}: FileUploaderProps) {
const [files, setFiles] = useState<FileWithPreview[]>([]);
useEffect(() => {
return () => {
files.forEach(file => {
if (file.preview) {
}
});
};
}, [files]);
const getFileIcon = useCallback((file: DocumentPicker.DocumentPickerAsset) => {
if (file.mimeType?.startsWith('image/')) return <AntDesign name="file-image" size={24} />;
return <AntDesign name="folder-open" size={24} />;
}, []);
const formatFileSize = useCallback((bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}, []);
const validateFile = useCallback((file: DocumentPicker.DocumentPickerAsset) => {
if (file.size && file.size > maxSize) {
return `File size exceeds ${formatFileSize(maxSize)}`;
}
const fileType = file.mimeType || '';
const isAccepted = accept.some(type => {
if (type.endsWith('/*')) {
return fileType.startsWith(type.slice(0, -1));
}
return fileType === type;
});
if (!isAccepted && accept.length > 0) {
return 'File type not supported';
}
return null;
}, [maxSize, accept, formatFileSize]);
const addFiles = useCallback((newFiles: DocumentPicker.DocumentPickerAsset[]) => {
if (files.length >= maxFiles) return;
const filesToAdd = newFiles.slice(0, maxFiles - files.length);
const processedFiles = filesToAdd.map(file => {
const error = validateFile(file);
return {
id: Math.random().toString(36).substring(2, 11),
file,
name: file.name,
size: file.size || 0,
type: file.mimeType || 'unknown',
progress: error ? 0 : 100,
status: error ? 'error' : 'complete',
error,
preview: file.mimeType?.startsWith('image/') ? file.uri : null
} as FileWithPreview;
});
const newFileList = [...files, ...processedFiles];
setFiles(newFileList);
const validFiles = newFileList.filter(f => !f.error).map(f => f.file);
if (onFilesReady) {
onFilesReady(validFiles);
}
}, [files, maxFiles, validateFile, onFilesReady]);
const removeFile = useCallback((fileId: string) => {
setFiles(prevFiles => {
const updatedFiles = prevFiles.filter(f => f.id !== fileId);
if (onFilesReady) {
const validFiles = updatedFiles.filter(f => !f.error).map(f => f.file);
onFilesReady(validFiles);
}
return updatedFiles;
});
}, [onFilesReady]);
const clearAllFiles = useCallback(() => {
setFiles([]);
if (onFilesReady) {
onFilesReady([]);
}
}, [onFilesReady]);
const updateFile = useCallback((fileId: string, updates: Partial<FileWithPreview>) => {
setFiles(prevFiles => {
const updatedFiles = prevFiles.map(f =>
f.id === fileId ? { ...f, ...updates } : f
);
if (onFilesReady) {
const validFiles = updatedFiles.filter(f => !f.error).map(f => f.file);
onFilesReady(validFiles);
}
return updatedFiles;
});
}, [onFilesReady]);
const contextValue: FileUploaderContextType = {
files,
maxFiles,
maxSize,
accept,
onFilesReady,
addFiles,
removeFile,
clearAllFiles,
updateFile,
formatFileSize,
validateFile,
getFileIcon,
};
return (
<FileUploaderContext.Provider value={contextValue}>
<View className={cn("w-full gap-4", className)}>
{children}
</View>
</FileUploaderContext.Provider>
);
}
FileUploader.DropZone = FileUploaderDropZone;
FileUploader.FileList = FileUploaderFileList;
FileUploader.Progress = FileUploaderProgress;
FileUploader.Preview = FileUploaderPreview;
export { FileUploaderDropZone, FileUploaderFileList, FileUploaderProgress, FileUploaderPreview };