import React, { useState, useEffect, useRef } from 'react'; import { View, StatusBar, Alert, ActivityIndicator, Text, AppState } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useCameraPermission, type Camera } from 'react-native-vision-camera'; import * as ImagePicker from 'expo-image-picker'; import * as FileSystem from 'expo-file-system/legacy'; import * as Haptics from 'expo-haptics'; import AsyncStorage from '@react-native-async-storage/async-storage'; import Header from './src/components/Header'; import Viewfinder from './src/components/Viewfinder'; import TabSelector, { TabId } from './src/components/TabSelector'; import AdjustmentPanel from './src/components/AdjustmentPanel'; import CameraControls from './src/components/CameraControls'; import PreviewModal from './src/components/PreviewModal'; import { Recipe, ColorAdjustments, FrameId, GPSInfo } from './src/types'; import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils'; import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils'; import { resolvePhotoGPS } from './src/utils/photoGps'; import { processAndExportPhoto } from './src/utils/exportEngine'; // @ts-ignore import './global.css'; const SESSION_KEY = 'saved_session'; export default function App() { const [mode, setMode] = useState<'camera' | 'library'>('camera'); const [recipes, setRecipes] = useState([]); const [selectedRecipe, setSelectedRecipe] = useState(null); // Custom temporary overrides for the sliders const [adjustments, setAdjustments] = useState({ exposure: 0, contrast: 0, saturation: 0, temperature: 5500, tint: 0, highlight: 0, shadow: 0, denoise: 0, clarity: 0, grain: 0, colorChrome: 'none', }); const [selectedFrame, setSelectedFrame] = useState('none'); const [useGeotag, setUseGeotag] = useState(true); const [gpsInfo, setGpsInfo] = useState(null); const [activeTab, setActiveTab] = useState('recipes'); const [libraryImageUri, setLibraryImageUri] = useState(null); const [lastPhotoUri, setLastPhotoUri] = useState(null); const [lastSavedToLibrary, setLastSavedToLibrary] = useState(false); const [previewVisible, setPreviewVisible] = useState(false); const [isProcessing, setIsProcessing] = useState(false); // Hardware Permissions (vision-camera) const { hasPermission: cameraPermissionGranted, requestPermission: requestCameraPermission } = useCameraPermission(); const cameraRef = useRef(null); // vision-camera may return a raw absolute path on Android; surface a file:// URI. const toUri = (path: string) => (/^(file|content|ph):\/\//.test(path) ? path : `file://${path}`); useEffect(() => { // B5: run a single sequenced init — restore the session first, then fall // back to the first recipe only when no recipe was restored. initSession(); // Do NOT auto-load device GPS here — it would override photo GPS on library mode }, []); // Save/restore session on app lifecycle changes useEffect(() => { const subscription = AppState.addEventListener('change', async (nextAppState) => { if (nextAppState === 'background' || nextAppState === 'inactive') { const session = { mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipeId: selectedRecipe?.id || null, gpsInfo, }; try { await AsyncStorage.setItem(SESSION_KEY, JSON.stringify(session)); } catch (e) { console.warn('Failed to save session:', e); } } }); return () => subscription.remove(); }, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo]); const initSession = async () => { const list = await getAllRecipes(); setRecipes(list); let session: any = null; try { const raw = await AsyncStorage.getItem(SESSION_KEY); if (raw) session = JSON.parse(raw); } catch (e) { console.warn('Failed to read session:', e); } let restoredRecipe: Recipe | null = null; if (session) { // B12: the restored library image may no longer exist on disk let imageUri: string | null = session.libraryImageUri || null; if (imageUri) { try { const info = await FileSystem.getInfoAsync(imageUri); if (!info.exists) imageUri = null; } catch { imageUri = null; } } if (imageUri) { setLibraryImageUri(imageUri); setMode('library'); } else { setMode(session.mode || 'camera'); } if (session.adjustments) setAdjustments(session.adjustments); if (session.selectedFrame) setSelectedFrame(session.selectedFrame); if (session.useGeotag !== undefined) setUseGeotag(session.useGeotag); if (session.gpsInfo) setGpsInfo(session.gpsInfo); if (session.selectedRecipeId) { restoredRecipe = list.find((r: Recipe) => r.id === session.selectedRecipeId) || null; } } if (restoredRecipe) { applyRecipe(restoredRecipe); } else if (list.length > 0) { applyRecipe(list[0]); } }; const loadGPS = async () => { const info = await getCurrentGPS(); if (info) { setGpsInfo({ ...info, source: 'device' }); } }; const applyRecipe = (recipe: Recipe) => { setSelectedRecipe(recipe); setAdjustments(recipe.adjustments); setSelectedFrame(recipe.frameId || 'none'); setUseGeotag(recipe.useGeotag); }; const handleUpdateAdjustments = (updates: Partial) => { // B8: never call a setState inside another setState's updater. const next = { ...adjustments, ...updates }; setAdjustments(next); if (selectedRecipe) { setSelectedRecipe({ ...selectedRecipe, adjustments: next, }); } }; const handleUpdateFrame = (frameId: FrameId) => { setSelectedFrame(frameId); if (selectedRecipe) { setSelectedRecipe({ ...selectedRecipe, frameId, }); } }; const handleToggleGeotag = async (enabled: boolean) => { setUseGeotag(enabled); if (selectedRecipe) { setSelectedRecipe({ ...selectedRecipe, useGeotag: enabled, }); } if (enabled && !gpsInfo) { const locationGranted = await requestLocationPermissions(); if (locationGranted) { loadGPS(); } else { Alert.alert('Permission Denied', 'Enable location services to use GPS Watermark.'); setUseGeotag(false); } } }; const handleSaveRecipe = async (name: string) => { if (!selectedRecipe) return; const newRecipe = await saveCustomRecipe({ name, baseFilter: selectedRecipe.baseFilter, adjustments, frameId: selectedFrame, useGeotag, }); const list = await getAllRecipes(); setRecipes(list); applyRecipe(newRecipe); Alert.alert('Recipe Saved', `"${name}" has been created.`); }; const handleDeleteRecipe = async (id: string) => { await deleteCustomRecipe(id); const list = await getAllRecipes(); setRecipes(list); if (selectedRecipe?.id === id && list.length > 0) { applyRecipe(list[0]); } }; const handlePickImage = async () => { if (isProcessing) return; const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: false, quality: 1, exif: true, }); if (!result.canceled && result.assets && result.assets.length > 0) { const asset = result.assets[0]; setLibraryImageUri(asset.uri); setMode('library'); // Clear old GPS — will be replaced if the photo (or device) has location data setGpsInfo(null); // Multi-tier GPS resolution: EXIF → MediaLibrary → device fallback const { gps, permissionDenied } = await resolvePhotoGPS(asset, { useGeotag, allowDeviceFallback: useGeotag, }); setGpsInfo(gps); if (permissionDenied) { console.warn('Media library read permission denied — photo GPS unavailable.'); } Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } }; const activeRecipe: Recipe = selectedRecipe || { id: 'temp', name: 'Custom', baseFilter: 'none', adjustments, frameId: selectedFrame, useGeotag, }; const handleCapture = async () => { if (isProcessing) return; // Camera mode (Phase 4, option A): real capture through vision-camera. if (mode === 'camera') { if (!cameraPermissionGranted) { Alert.alert('Permission Needed', 'Grant camera permission to take photos.'); return; } if (!cameraRef.current) { Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.'); return; } setIsProcessing(true); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); try { // A photo taken now is at the device's current spot — resolve device GPS // fresh instead of reusing GPS left over from a previously loaded photo. let captureGps = gpsInfo && gpsInfo.source === 'device' ? gpsInfo : null; if (useGeotag && !captureGps) { const deviceGps = await getCurrentGPS(); if (deviceGps) { captureGps = { ...deviceGps, source: 'device' as const }; setGpsInfo(captureGps); } } const photo = await cameraRef.current.takePhoto({}); const sourceUri = toUri(photo.path); const result = await processAndExportPhoto( sourceUri, { ...activeRecipe, adjustments, frameId: selectedFrame, useGeotag }, selectedFrame, useGeotag, captureGps, { dpi: 300, sharpen: true } // 300dpi header + full-res screen sharpen ); if (result) { setLastPhotoUri(result.uri); setLastSavedToLibrary(result.savedToLibrary); setPreviewVisible(true); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } else { Alert.alert('Failed', 'Error occurred while processing the captured photo.'); } } catch (error) { console.error('Camera capture failed:', error); Alert.alert('Capture Failed', 'Could not take the photo. See logs for details.'); } finally { setIsProcessing(false); } return; } // Library mode capture/export: processes selected image with recipe, overlays, text if (!libraryImageUri || !selectedRecipe) { Alert.alert('Error', 'Please select an image from the library first.'); return; } setIsProcessing(true); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); const result = await processAndExportPhoto( libraryImageUri, { ...selectedRecipe, adjustments, frameId: selectedFrame, useGeotag, }, selectedFrame, useGeotag, gpsInfo ); setIsProcessing(false); if (result) { setLastPhotoUri(result.uri); setLastSavedToLibrary(result.savedToLibrary); setPreviewVisible(true); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } else { Alert.alert('Failed', 'Error occurred while processing image.'); } }; // Switching back to camera: drop GPS that belongs to a library photo, so the // live view (and any capture) never stamps a stale location. const handleSetMode = (next: 'camera' | 'library') => { if (next === 'camera' && gpsInfo && gpsInfo.source !== 'device') { setGpsInfo(null); } setMode(next); }; return ( {/* Top Header */}
{/* Main Viewfinder Frame */} {/* Selector Tabs */} {/* Adjustments Panel */} {/* Bottom Camera Buttons */} setPreviewVisible(true)} isLibraryMode={mode === 'library'} onSwitchToCamera={() => handleSetMode('camera')} /> {/* Preview Modal */} setPreviewVisible(false)} photoUri={lastPhotoUri} recipeName={selectedRecipe?.name || 'Custom'} savedToLibrary={lastSavedToLibrary} /> {/* Global Processing Loader Overlay */} {isProcessing && ( RENDERING PHOTO... )} ); }