/** * Ux4gFileUpload * * React Native port of the Flutter `Ux4gFileUpload` component (`file_upload.dart`). * Supports real file picking (document picker) and camera scanning (image picker). * Features: solid/dashed border styles, upload progress, error/retry states, * file type badge icons, and Ux4gToast error notifications. */ import React, { useState, useCallback, useRef, useEffect } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, StyleProp, TextStyle, ViewStyle, Platform, PermissionsAndroid, } from 'react-native'; import { useUx4gTheme } from '../../theme/Ux4gThemeContext'; import { UX4GColors } from '../../foundation/colors'; import { Ux4gIcons } from '../../foundation/icons'; import { Ux4gButton } from '../button/Button'; // ── SVG imports for dashed border ─────────────────────────────────────────── let Svg: any = null; let Rect: any = null; try { const RNSvg = require('react-native-svg'); Svg = RNSvg.Svg || RNSvg.default; Rect = RNSvg.Rect; } catch (_e) { // react-native-svg not present } // ── Optional native picker imports ────────────────────────────────────────── let DocumentPicker: any = null; try { DocumentPicker = require('react-native-document-picker').default; } catch (_e) { // react-native-document-picker not installed } let launchCamera: any = null; try { launchCamera = require('react-native-image-picker').launchCamera; } catch (_e) { // react-native-image-picker not installed } // ── Toast hook (graceful fallback when outside provider) ──────────────────── let useUx4gToastSafe: () => { showToast: (data: any) => void; dismiss: () => void } | null; try { const toastModule = require('../toast/Toast'); useUx4gToastSafe = () => { try { return toastModule.useUx4gToast(); } catch (_e) { return null; } }; } catch (_e) { useUx4gToastSafe = () => null; } // ═══════════════════════════════════════════════════════════════════════════ // PUBLIC TYPES // ═══════════════════════════════════════════════════════════════════════════ export enum UploadStatus { idle = 'idle', uploading = 'uploading', success = 'success', error = 'error', } export interface UploadedFile { id: string; name: string; fileSize: number; // bytes status: UploadStatus; progress: number; // 0.0 – 1.0 errorMessage?: string; uri?: string; } export type Ux4gFileUploadBorderStyle = 'solid' | 'dashed'; export interface Ux4gFileUploadProps { maxFiles?: number; maxFileSize?: number; // bytes, default 5 MB onFilesChanged?: (files: UploadedFile[]) => void; onUpload?: (file: UploadedFile) => Promise; allowedExtensions?: string[]; borderStyle?: Ux4gFileUploadBorderStyle; buttonBorderRadius?: number; buttonColor?: string; buttonBorderColor?: string; buttonStyle?: StyleProp; errorTitle?: string; errorText?: string; errorTitleStyle?: StyleProp; errorTextStyle?: StyleProp; /** Pre-populated initial files for testing / showcase */ initialFiles?: UploadedFile[]; } // ═══════════════════════════════════════════════════════════════════════════ // HELPERS // ═══════════════════════════════════════════════════════════════════════════ export function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } function getFileExtension(name: string): string { return name.split('.').pop()?.toLowerCase() ?? ''; } function getFileIconInfo(fileName: string): { iconName: string; iconColor: string; bgColor: string; } { const ext = getFileExtension(fileName); if (['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(ext)) { return { iconName: 'image', iconColor: UX4GColors.blue600, bgColor: UX4GColors.blue50 }; } if (ext === 'pdf') { return { iconName: 'pdf', iconColor: UX4GColors.red600, bgColor: UX4GColors.red50 }; } if (['doc', 'docx'].includes(ext)) { return { iconName: 'description', iconColor: UX4GColors.primary600, bgColor: UX4GColors.primary50 }; } return { iconName: 'file', iconColor: UX4GColors.neutral500, bgColor: UX4GColors.neutral100 }; } // ═══════════════════════════════════════════════════════════════════════════ // COMPONENT // ═══════════════════════════════════════════════════════════════════════════ export const Ux4gFileUpload: React.FC = ({ maxFiles = 5, maxFileSize = 5 * 1024 * 1024, onFilesChanged, onUpload, allowedExtensions = ['jpg', 'png', 'pdf'], borderStyle = 'solid', buttonBorderRadius = 8, buttonColor, buttonBorderColor, buttonStyle, errorTitle, errorText, errorTitleStyle, errorTextStyle, initialFiles, }) => { const theme = useUx4gTheme(); const toast = useUx4gToastSafe?.() ?? null; const primary = theme.colors.primary; const onPrimary = theme.colors.onPrimary; const onBackground = theme.colors.onBackground; const onSurface = theme.colors.onSurface; const errorColor = theme.colors.error; const [files, setFiles] = useState(initialFiles ?? []); const [hasGlobalError, setHasGlobalError] = useState(false); const [currentErrorTitle, setCurrentErrorTitle] = useState(); const [currentErrorText, setCurrentErrorText] = useState(); // Sync initialFiles if changed const prevInitialRef = useRef(initialFiles); useEffect(() => { if (initialFiles && initialFiles !== prevInitialRef.current) { const same = prevInitialRef.current && prevInitialRef.current.length === initialFiles.length && prevInitialRef.current.every((f, i) => f.id === initialFiles[i]?.id); if (!same) { setFiles(initialFiles); prevInitialRef.current = initialFiles; } } }, [initialFiles]); // ── Error display ────────────────────────────────────────────────────── const showError = useCallback( (title: string, message: string, _fileName?: string) => { const displayTitle = errorTitle ?? title; let displayText = errorText ?? message; if (_fileName) { displayText = displayText.replace('[File Name]', _fileName); } setHasGlobalError(true); setCurrentErrorTitle(displayTitle); setCurrentErrorText(displayText); toast?.showToast({ category: 'error' as any, // Match Flutter behavior: toast uses original title/message. title, subtitle: message, showCloseButton: true, autoClose: true, durationMs: 4000, }); }, [errorTitle, errorText, toast], ); const requestAndroidPermission = useCallback( async ( permission: (typeof PermissionsAndroid.PERMISSIONS)[keyof typeof PermissionsAndroid.PERMISSIONS], title: string, message: string, ): Promise => { if (Platform.OS !== 'android') return true; try { const granted = await PermissionsAndroid.request(permission as any, { title, message, buttonNeutral: 'Ask Me Later', buttonNegative: 'Cancel', buttonPositive: 'OK', }); return granted === PermissionsAndroid.RESULTS.GRANTED; } catch (err: any) { showError('Permission Error', `Failed to request permission: ${err?.message ?? err}`); return false; } }, [showError], ); // ── Upload simulation ────────────────────────────────────────────────── const uploadFile = useCallback( async (file: UploadedFile) => { setFiles((prev) => { const idx = prev.findIndex((f) => f.id === file.id); if (idx === -1) return prev; const updated = [...prev]; updated[idx] = { ...updated[idx], status: UploadStatus.uploading }; return updated; }); try { // Simulate progress for (let i = 0; i <= 10; i++) { await new Promise((r) => setTimeout(r, 200)); setFiles((prev) => { const idx = prev.findIndex((f) => f.id === file.id); if (idx === -1) return prev; const updated = [...prev]; updated[idx] = { ...updated[idx], progress: i / 10 }; return updated; }); } const success = onUpload ? await onUpload(file) : true; setFiles((prev) => { const idx = prev.findIndex((f) => f.id === file.id); if (idx === -1) return prev; const updated = [...prev]; if (success) { updated[idx] = { ...updated[idx], status: UploadStatus.success, progress: 1.0 }; } else { updated[idx] = { ...updated[idx], status: UploadStatus.error, errorMessage: 'Upload failed. Please try again.', }; } return updated; }); } catch (e: any) { setFiles((prev) => { const idx = prev.findIndex((f) => f.id === file.id); if (idx === -1) return prev; const updated = [...prev]; updated[idx] = { ...updated[idx], status: UploadStatus.error, errorMessage: `Error: ${e?.message ?? e}`, }; return updated; }); } // Notify parent setFiles((prev) => { onFilesChanged?.(prev); return prev; }); }, [onUpload, onFilesChanged], ); // ── Pick file (from document picker or camera) ───────────────────────── const pickFile = useCallback( async (fromCamera: boolean) => { setHasGlobalError(false); try { let fileName: string; let fileSize: number; let fileUri: string; if (fromCamera) { if (!launchCamera) { showError('Missing Dependency', 'react-native-image-picker is not installed.'); return; } const hasCameraPermission = await requestAndroidPermission( PermissionsAndroid.PERMISSIONS.CAMERA, 'Camera Permission Required', 'This app needs access to your camera to scan documents.', ); if (!hasCameraPermission) { showError('Permission Denied', 'Camera permission is required to scan documents.'); return; } const result = await new Promise((resolve) => { launchCamera( { mediaType: 'photo', quality: 0.8, saveToPhotos: false }, (response: any) => resolve(response), ); }); if (result.didCancel) return; if (result.errorCode === 'permission') { showError( 'Permission Denied', 'Camera permission denied. Please allow camera access in your device Settings.', ); return; } if (result.errorCode === 'camera_unavailable') { showError('Camera Unavailable', 'Camera is not available on this device.'); return; } if (result.errorCode) { showError('Camera Error', result.errorMessage ?? 'An error occurred while launching camera.'); return; } const asset = result.assets?.[0]; if (!asset) return; fileName = asset.fileName ?? `camera_${Date.now()}.jpg`; fileSize = asset.fileSize ?? 0; fileUri = asset.uri ?? ''; } else { if (!DocumentPicker) { showError('Missing Dependency', 'react-native-document-picker is not installed.'); return; } if (Platform.OS === 'android') { const androidVersion = typeof Platform.Version === 'number' ? Platform.Version : parseInt(String(Platform.Version), 10); const readPermission = androidVersion >= 33 ? PermissionsAndroid.PERMISSIONS.READ_MEDIA_IMAGES : PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE; const hasReadPermission = await requestAndroidPermission( readPermission, 'Storage Permission Required', 'This app needs access to your files to upload documents.', ); if (!hasReadPermission) { showError('Permission Denied', 'Storage permission is required to upload documents.'); return; } } const result = await DocumentPicker.pick({ type: allowedExtensions.map((ext: string) => { if (ext === 'pdf') return 'application/pdf'; if (['jpg', 'jpeg'].includes(ext)) return 'image/jpeg'; if (ext === 'png') return 'image/png'; if (ext === 'gif') return 'image/gif'; if (['doc', 'docx'].includes(ext)) return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; return '*/*'; }), }); const picked = Array.isArray(result) ? result[0] : result; if (!picked) return; fileName = picked.name ?? `file_${Date.now()}`; fileSize = picked.size ?? 0; fileUri = picked.uri ?? ''; } // Validate file size if (fileSize > maxFileSize) { showError( 'File too large', `${fileName} exceeds the ${Math.floor(maxFileSize / (1024 * 1024))}MB upload limit. Please compress it or select a smaller file.`, fileName, ); return; } // Validate max files if (files.length >= maxFiles) { showError('Max files reached', `You can upload maximum ${maxFiles} files`); return; } const newFile: UploadedFile = { id: Date.now().toString(), name: fileName, fileSize, status: UploadStatus.idle, progress: 0, uri: fileUri, }; setFiles((prev) => { const next = [...prev, newFile]; onFilesChanged?.(next); return next; }); // Start upload if (onUpload) { await uploadFile(newFile); } } catch (e: any) { // Handle user cancellation if (DocumentPicker && DocumentPicker.isCancel?.(e)) return; showError('Error', `Failed to pick file: ${e?.message ?? e}`); } }, [files.length, maxFiles, maxFileSize, allowedExtensions, onUpload, showError, uploadFile, onFilesChanged, requestAndroidPermission], ); // ── Retry upload ─────────────────────────────────────────────────────── const retryUpload = useCallback( async (file: UploadedFile) => { setFiles((prev) => { const idx = prev.findIndex((f) => f.id === file.id); if (idx === -1) return prev; const updated = [...prev]; updated[idx] = { ...updated[idx], status: UploadStatus.idle, progress: 0, errorMessage: undefined, }; return updated; }); if (onUpload) { await uploadFile(file); } }, [onUpload, uploadFile], ); // ── Remove file ──────────────────────────────────────────────────────── const removeFile = useCallback( (id: string) => { setFiles((prev) => { const next = prev.filter((f) => f.id !== id); onFilesChanged?.(next); return next; }); }, [onFilesChanged], ); // ── Border color ─────────────────────────────────────────────────────── const borderColor = hasGlobalError ? errorColor : primary; // ── Typography tokens ────────────────────────────────────────────────── const tsStrong = theme.typography.tS_strong; const bsDefault = theme.typography.bS_default; // ═════════════════════════════════════════════════════════════════════════ // UPLOAD CONTENT (shared between solid / dashed variants) // ═════════════════════════════════════════════════════════════════════════ const uploadContent = ( {/* Icon */} {hasGlobalError ? Ux4gIcons.errorOutline({ size: 32, color: errorColor }) : Ux4gIcons.cloudUpload({ size: 32, color: primary })} {/* Title */} {hasGlobalError ? (currentErrorTitle ?? 'Could not scan') : 'Upload Documents'} {/* Description */} {hasGlobalError ? (currentErrorText ?? 'File exceeds the upload limit.') : `File type: ${allowedExtensions.map((e) => e.toUpperCase()).join(' ')} Max size: ${Math.floor(maxFileSize / (1024 * 1024))} MB`} {/* Buttons Row */} {/* Upload Button (Outline Variant) */} Ux4gIcons.cloudUpload({ size, color: buttonColor ?? color }) } borderColor={buttonBorderColor ?? primary} contentColor={buttonColor ?? primary} borderRadius={buttonBorderRadius} style={buttonStyle} onPress={() => pickFile(false)} /> {/* Scan Button (Primary Variant) */} Ux4gIcons.camera({ size, color: onPrimary }) } backgroundColor={buttonColor ?? primary} contentColor={onPrimary} borderRadius={buttonBorderRadius} style={buttonStyle} onPress={() => pickFile(true)} /> ); // ═════════════════════════════════════════════════════════════════════════ // DROP ZONE // ═════════════════════════════════════════════════════════════════════════ const dropZone = borderStyle === 'dashed' ? ( {/* SVG dashed border */} {Svg && Rect && ( )} {uploadContent} ) : ( {uploadContent} ); // ═════════════════════════════════════════════════════════════════════════ // FILE LIST ITEMS // ═════════════════════════════════════════════════════════════════════════ const renderFileItem = (file: UploadedFile) => { const iconInfo = getFileIconInfo(file.name); return ( {/* Top row: icon + name + size + status + close */} {/* File type badge */} {iconInfo.iconName === 'image' ? Ux4gIcons.imageFile({ size: 20, color: iconInfo.iconColor }) : iconInfo.iconName === 'pdf' ? Ux4gIcons.pdf({ size: 20, color: iconInfo.iconColor }) : iconInfo.iconName === 'description' ? Ux4gIcons.docFile({ size: 20, color: iconInfo.iconColor }) : Ux4gIcons.insertDriveFile({ size: 20, color: iconInfo.iconColor })} {/* File info */} {file.name} {formatFileSize(file.fileSize)} {/* Success check */} {file.status === UploadStatus.success && ( {Ux4gIcons.checkCircle({ size: 22, color: UX4GColors.green600 })} )} {/* Close button */} removeFile(file.id)} hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }} > {Ux4gIcons.close({ size: 20, color: UX4GColors.neutral500 })} {/* Progress bar */} {file.status === UploadStatus.uploading && ( {`${Math.round(file.progress * 100)}%`} )} {/* Error row */} {file.status === UploadStatus.error && ( {Ux4gIcons.errorOutline({ size: 14, color: UX4GColors.red600 })} {file.errorMessage ?? 'Upload failed'} retryUpload(file)}> Retry )} ); }; // ═════════════════════════════════════════════════════════════════════════ // RENDER // ═════════════════════════════════════════════════════════════════════════ return ( {dropZone} {files.length > 0 && {files.map(renderFileItem)}} ); }; // ═══════════════════════════════════════════════════════════════════════════ // LOCAL STYLES // ═══════════════════════════════════════════════════════════════════════════ const localStyles = StyleSheet.create({ solidContainer: { width: '100%', }, dashedContainer: { width: '100%', position: 'relative', }, uploadPadding: { padding: 24, }, uploadContentColumn: { alignItems: 'center', }, buttonRow: { flexDirection: 'row', width: '100%', }, uploadBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 12, borderWidth: 1.5, }, scanBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 12, }, fileItemOuter: { marginBottom: 12, }, fileItemContainer: { borderWidth: 1, borderRadius: 8, overflow: 'hidden', }, fileRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, }, fileIconBadge: { width: 40, height: 40, borderRadius: 8, justifyContent: 'center', alignItems: 'center', }, progressRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingBottom: 10, }, progressTrack: { flex: 1, height: 4, backgroundColor: UX4GColors.neutral200, borderRadius: 4, overflow: 'hidden', }, progressFill: { height: '100%', borderRadius: 4, }, errorRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingBottom: 10, }, });