import React, { useState, useEffect } from 'react'; import { View, StatusBar, Alert, ActivityIndicator, Text, AppState, Platform } from 'react-native'; import { useCameraPermission, usePhotoOutput } from 'react-native-vision-camera'; import * as ImagePicker from 'expo-image-picker'; import * as FileSystem from 'expo-file-system/legacy'; import * as MediaLibrary from 'expo-media-library'; import * as Haptics from 'expo-haptics'; import AsyncStorage from '@react-native-async-storage/async-storage'; import TopBar from './src/components/TopBar'; import Viewfinder from './src/components/Viewfinder'; import AdjustmentPanel from './src/components/AdjustmentPanel'; import CameraControls from './src/components/CameraControls'; import ToolRail, { TabId } from './src/components/ToolRail'; import PreviewModal from './src/components/PreviewModal'; import { Recipe, ColorAdjustments, FrameId, GPSInfo } from './src/types'; import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils'; import { DEFAULT_ADJUSTMENTS } from './src/utils/defaultRecipes'; 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({ ...DEFAULT_ADJUSTMENTS }); const [selectedFrame, setSelectedFrame] = useState('none'); const [useGeotag, setUseGeotag] = useState(true); const [gpsInfo, setGpsInfo] = useState(null); // Progressive disclosure: a tool chip opens its panel; null = photo hero only. const [openTab, setOpenTab] = useState(null); 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(); // Full-resolution photo output — attached to the live in Viewfinder. // Photo capture is resolution-independent from the Skia preview output. const photoOutput = usePhotoOutput({ qualityPrioritization: 'quality', }); // 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({ ...DEFAULT_ADJUSTMENTS, ...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; // On Android 10+, picking without ACCESS_MEDIA_LOCATION makes the OS hand us // a redacted copy (EXIF GPS stripped). That silently kills Tier-1 EXIF GPS // and the photo's own coordinates (often a different place than where the // user stands now) get replaced by a device-GPS fallback. Request media // read + photo-location BEFORE the pick so the copy keeps its true EXIF GPS. let mediaOk = true; if (useGeotag && Platform.OS === 'android') { try { const perm = await MediaLibrary.requestPermissionsAsync(false, ['photo']); mediaOk = perm.granted; console.error(`[gps] pre-pick media+location permission granted=${perm.granted}`); } catch (e) { mediaOk = false; console.error('[gps] pre-pick media permission error: ' + String(e).slice(0, 120)); } } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: false, quality: 1, exif: true, // Android 13+ photo picker always returns a redacted cache copy with // assetId=null — EXIF GPS stripped, MediaStore id lost → photo GPS is // unreachable and we'd stamp the device's location instead. The legacy // picker (ACTION_GET_CONTENT → DocumentsUI) hands back a // com.android.providers.media.documents uri, so the MediaStore asset id // survives AND (with ACCESS_MEDIA_LOCATION granted) the file is not // redacted → tier-1 EXIF / tier-2 MediaStore both work. legacy: Platform.OS === 'android' && useGeotag, }); if (!result.canceled && result.assets && result.assets.length > 0) { const asset = result.assets[0]; console.error(`[gps] picked uri=${asset.uri} assetId=${asset.assetId || 'null'} exifGPS=${asset.exif ? JSON.stringify(asset.exif.GPSLatitude ?? null) : 'no-exif'}`); 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. // Device fallback only when media read was granted (else we cannot know // whether the photo carries its own GPS — stamping the device's current // spot over a photo from somewhere else would be wrong). const { gps, permissionDenied } = await resolvePhotoGPS(asset, { useGeotag, allowDeviceFallback: useGeotag && mediaOk, }); setGpsInfo(gps); if (permissionDenied) { console.warn('Media library read permission denied — photo GPS unavailable.'); } console.error( `[gps] resolved ${gps ? `src=${gps.source} lat=${gps.latitude.toFixed(5)} lon=${gps.longitude.toFixed(5)} loc=${(gps.locality || '').slice(0, 40)}` : 'none'}` ); 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 (!photoOutput) { 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 tShot = Date.now(); const { filePath } = await photoOutput.capturePhotoToFile({}, {}); console.error(`[capture] shot-took ${Date.now() - tShot}ms`); const sourceUri = toUri(filePath); const tExp = Date.now(); const result = await processAndExportPhoto( sourceUri, { ...activeRecipe, adjustments, frameId: selectedFrame, useGeotag }, selectedFrame, useGeotag, captureGps, // 300dpi header + full-res screen sharpen. evFromCamera: the AE bias // already applied exposure compensation at capture — don't add 2^EV. { dpi: 300, sharpen: true, evFromCamera: true } ); console.error(`[capture] export-took ${Date.now() - tExp}ms (pipeline total ${Date.now() - tShot}ms)`); 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 ( {/* Fullscreen hero: photo or live preview fills the whole screen */} handleUpdateAdjustments({ exposureCompensation: ev })} /> {/* Top overlay: mode label + recipe name (left), Save current (right) */} {/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */} {openTab !== null && ( )} setOpenTab(openTab === tab ? null : tab)} /> 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... )} ); }