import React, { useState, useEffect } from 'react'; import { View, StatusBar, Alert, ActivityIndicator, Text, AppState } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useCameraPermissions } from 'expo-camera'; import * as ImagePicker from 'expo-image-picker'; import * as MediaLibrary from 'expo-media-library'; 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, reverseGeocode } from './src/utils/locationUtils'; 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 [previewVisible, setPreviewVisible] = useState(false); const [isProcessing, setIsProcessing] = useState(false); // Hardware Permissions (expo-camera) const [cameraPermission, requestCameraPermission] = useCameraPermissions(); useEffect(() => { loadRecipes(); restoreSession(); // 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 restoreSession = async () => { try { const saved = await AsyncStorage.getItem(SESSION_KEY); if (!saved) return; const session = JSON.parse(saved); if (session.libraryImageUri) { setLibraryImageUri(session.libraryImageUri); 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) { const allRecipes = await getAllRecipes(); const found = allRecipes.find((r: Recipe) => r.id === session.selectedRecipeId); if (found) { setSelectedRecipe(found); } } } catch (e) { console.warn('Failed to restore session:', e); } }; const loadRecipes = async () => { const list = await getAllRecipes(); setRecipes(list); if (list.length > 0) { applyRecipe(list[0]); } }; const loadGPS = async () => { const info = await getCurrentGPS(); if (info) { setGpsInfo(info); } }; const applyRecipe = (recipe: Recipe) => { setSelectedRecipe(recipe); setAdjustments(recipe.adjustments); setSelectedFrame(recipe.frameId || 'none'); setUseGeotag(recipe.useGeotag); }; const handleUpdateAdjustments = (updates: Partial) => { setAdjustments((prev) => { const next = { ...prev, ...updates }; if (selectedRecipe) { setSelectedRecipe({ ...selectedRecipe, adjustments: next, }); } return 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 () => { 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 photo has location data setGpsInfo(null); // Multi-tier GPS resolution: EXIF → MediaLibrary.getAssetInfoAsync const resolvedGps = await resolvePhotoLocation(asset); if (resolvedGps) { setGpsInfo(resolvedGps); } else if (useGeotag) { // Tier 3: photo has no GPS — fall back to device location const deviceGps = await getCurrentGPS(); if (deviceGps) { setGpsInfo(deviceGps); } } Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } }; // Multi-tier GPS resolver: EXIF → MediaLibrary → Device GPS (fallback inside handlePickImage) const resolvePhotoLocation = async (asset: any): Promise => { // Tier 1: Extract GPS from ImagePicker EXIF if (asset.exif && (asset.exif.GPSLatitude || asset.exif.GPSLongitude)) { const lat = typeof asset.exif.GPSLatitude === 'number' ? asset.exif.GPSLatitude : parseGPS(asset.exif.GPSLatitudeRef || '', asset.exif.GPSLatitude); const lon = typeof asset.exif.GPSLongitude === 'number' ? asset.exif.GPSLongitude : parseGPS(asset.exif.GPSLongitudeRef || '', asset.exif.GPSLongitude); if (lat !== 0 || lon !== 0) { return { latitude: lat, longitude: lon, locality: undefined, country: undefined, timestamp: Date.now(), }; } } // Tier 2: Use MediaLibrary.getAssetInfoAsync for richer metadata // On Android, assetId is a content URI like "content://media/external/images/media/12345" // but MediaLibrary expects just the numeric part "12345" const rawId = asset.assetId || ''; const numericId = rawId.replace(/^.*\/media\//, '').replace(/\/.*$/, ''); if (numericId && /^\d+$/.test(numericId)) { try { const assetInfo = await MediaLibrary.getAssetInfoAsync(numericId); if (assetInfo.location) { const locality = await reverseGeocode(assetInfo.location.latitude, assetInfo.location.longitude) || undefined; return { latitude: assetInfo.location.latitude, longitude: assetInfo.location.longitude, locality, country: undefined, timestamp: Date.now(), }; } } catch (e) { console.warn('MediaLibrary getAssetInfoAsync failed:', e); } } return null; }; // Helper to parse GPS coordinates from EXIF string format const parseGPS = (ref: string, val: any): number => { try { if (Array.isArray(val)) { const [degrees, minutes, seconds] = val.map(Number); const decimal = degrees + minutes / 60 + seconds / 3600; return (ref === 'S' || ref === 'W') ? -decimal : decimal; } if (typeof val === 'number') return val; return 0; } catch { return 0; } }; const handleCapture = async () => { if (mode === 'camera') { // For simulator & simplicity, if camera view isn't fully mocked, we use a placeholder or trigger camera picture. // In this setup, we simulate taking a photo or request library image if running in simulator. // Let's create a beautiful simulation that pulls a mock asset or lets you select one if in simulator. Alert.alert( 'Capture Mode', 'Camera capture requires native device hardware. For testing on emulators, please use the LIBRARY mode to load a photo and apply filters.', [{ text: 'OK' }] ); } else { // 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 exportedUri = await processAndExportPhoto( libraryImageUri, { ...selectedRecipe, adjustments, frameId: selectedFrame, useGeotag, }, selectedFrame, useGeotag, gpsInfo ); setIsProcessing(false); if (exportedUri) { setLastPhotoUri(exportedUri); setPreviewVisible(true); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } else { Alert.alert('Failed', 'Error occurred while processing image.'); } } }; return ( {/* Top Header */}
{/* Main Viewfinder Frame */} {/* Selector Tabs */} {/* Adjustments Panel */} {/* Bottom Camera Buttons */} setPreviewVisible(true)} isLibraryMode={mode === 'library'} onSwitchToCamera={() => setMode('camera')} /> {/* Preview Modal */} setPreviewVisible(false)} photoUri={lastPhotoUri} recipeName={selectedRecipe?.name || 'Custom'} /> {/* Global Processing Loader Overlay */} {isProcessing && ( RENDERING PHOTO... )} ); }