diff --git a/app.json b/app.json index 98dc98a..abbd6cf 100644 --- a/app.json +++ b/app.json @@ -36,7 +36,14 @@ } ], "expo-font", - "expo-web-browser" + "expo-web-browser", + [ + "expo-image-picker", + { + "photosPermission": "Allow $(PRODUCT_NAME) to access your photos to upload receipt images for transaction extraction.", + "cameraPermission": "Allow $(PRODUCT_NAME) to access your camera to take photos of receipts for transaction extraction." + } + ] ], "experiments": { "typedRoutes": true diff --git a/components/receipt-form.tsx b/components/receipt-form.tsx index 31f6b48..337e49d 100644 --- a/components/receipt-form.tsx +++ b/components/receipt-form.tsx @@ -7,7 +7,6 @@ import { Image, ScrollView, ActivityIndicator, - Alert, } from "react-native"; import { useState } from "react"; import * as ImagePicker from "expo-image-picker"; @@ -15,6 +14,7 @@ import { MaterialCommunityIcons } from "@expo/vector-icons"; import { useTransactionExtraction } from "@/hooks/useTransactionExtraction"; import { useRouter } from "expo-router"; import { useTransactionStore } from "@/store/transactionStore"; +import { launchCamera, launchImageLibrary } from "@/utils/imagePicker"; export default function ReceiptForm() { const [selectedImage, setSelectedImage] = @@ -24,40 +24,16 @@ export default function ReceiptForm() { const { addBulkTransactions } = useTransactionStore(); const handleImagePick = async () => { - const permissionResult = - await ImagePicker.requestMediaLibraryPermissionsAsync(); - - if (!permissionResult.granted) { - Alert.alert("Permission to access camera roll is required!"); - return; - } - - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ["images"], - quality: 1, - allowsEditing: false, - aspect: [16, 9], - }); - - if (!result.canceled) { - setSelectedImage(result.assets[0]); + const result = await launchImageLibrary(); + if (result.success) { + setSelectedImage(result.asset); } }; const handleCameraButtonPress = async () => { - const permissionResult = await ImagePicker.requestCameraPermissionsAsync(); - if (!permissionResult.granted) { - Alert.alert("Permission to access camera is required!"); - return; - } - const result = await ImagePicker.launchCameraAsync({ - mediaTypes: ["images"], - quality: 1, - allowsEditing: false, - aspect: [16, 9], - }); - if (!result.canceled) { - setSelectedImage(result.assets[0]); + const result = await launchCamera(); + if (result.success) { + setSelectedImage(result.asset); } }; diff --git a/utils/imagePicker.ts b/utils/imagePicker.ts new file mode 100644 index 0000000..071e236 --- /dev/null +++ b/utils/imagePicker.ts @@ -0,0 +1,122 @@ +/** + * Image Picker Utilities + * + * Reusable functions for camera and gallery image selection with built-in + * permission handling and camera availability checks (iOS Simulator support). + * + * Usage: + * ```typescript + * import { launchCamera, launchImageLibrary } from "@/utils/imagePicker"; + * + * // Pick from gallery + * const result = await launchImageLibrary(); + * if (result.success) { + * console.log(result.asset.uri); + * } + * + * // Capture with camera + * const result = await launchCamera(); + * if (result.success) { + * setImage(result.asset); + * } + * ``` + * + * Note: Camera is not available on iOS Simulator. The `launchCamera` function + * will show an alert and return `{ success: false }` in that case. + */ + +import * as ImagePicker from "expo-image-picker"; +import { Alert } from "react-native"; + +export type ImagePickerResult = { + success: true; + asset: ImagePicker.ImagePickerAsset; +} | { + success: false; +}; + +/** + * Check if camera is available and show alert if not. + * Returns true if camera is available, false otherwise. + */ +export async function checkCameraAvailability(): Promise { + const cameraStatus = await ImagePicker.getCameraPermissionsAsync(); + + if (!cameraStatus.canAskAgain && !cameraStatus.granted) { + Alert.alert( + "Camera Not Available", + "Camera is not available on this device. Please use the gallery option instead, or test on a physical device.", + [{ text: "OK" }] + ); + return false; + } + + return true; +} + +/** + * Launch camera to capture an image. + * Handles permission requests and camera availability checks. + */ +export async function launchCamera(): Promise { + const isAvailable = await checkCameraAvailability(); + if (!isAvailable) { + return { success: false }; + } + + const permissionResult = await ImagePicker.requestCameraPermissionsAsync(); + if (!permissionResult.granted) { + Alert.alert("Permission to access camera is required!"); + return { success: false }; + } + + try { + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: ["images"], + quality: 1, + allowsEditing: false, + aspect: [16, 9], + }); + + if (!result.canceled && result.assets[0]) { + return { success: true, asset: result.assets[0] }; + } + + return { success: false }; + } catch (error) { + // Camera hardware not available (e.g., iOS Simulator) + Alert.alert( + "Camera Not Available", + "Camera is not available on this device. Please use the gallery option instead, or test on a physical device.", + [{ text: "OK" }] + ); + return { success: false }; + } +} + +/** + * Launch image library to pick an image. + * Handles permission requests. + */ +export async function launchImageLibrary(): Promise { + const permissionResult = + await ImagePicker.requestMediaLibraryPermissionsAsync(); + + if (!permissionResult.granted) { + Alert.alert("Permission to access camera roll is required!"); + return { success: false }; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + quality: 1, + allowsEditing: false, + aspect: [16, 9], + }); + + if (!result.canceled && result.assets[0]) { + return { success: true, asset: result.assets[0] }; + } + + return { success: false }; +}