import React, { useState, useEffect, useRef, useCallback } from 'react'; import { View, StatusBar, Alert, ActivityIndicator, Text, AppState, Platform, Keyboard } from 'react-native'; import { useCameraDevice, useCameraPermission, usePhotoOutput } from 'react-native-vision-camera'; import * as DocumentPicker from 'expo-document-picker'; import * as Sharing from 'expo-sharing'; import * as FileSystem from 'expo-file-system/legacy'; import * as MediaLibraryLegacy from 'expo-media-library/legacy'; import * as Haptics from 'expo-haptics'; import AsyncStorage from '@react-native-async-storage/async-storage'; import TopBar from './src/components/TopBar'; import Viewfinder, { type ViewfinderHandle } from './src/components/Viewfinder'; import AdjustmentPanel from './src/components/AdjustmentPanel'; import CameraControls from './src/components/CameraControls'; import ToolRail, { TabId } from './src/components/ToolRail'; import SettingsModal from './src/components/SettingsModal'; import PhotoViewerModal from './src/components/PhotoViewerModal'; import LibraryPickerModal from './src/components/LibraryPickerModal'; import ProModal from './src/components/ProModal'; import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, FlashMode, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT } from './src/types'; import { getAllRecipes, saveCustomRecipe, updateCustomRecipe, deleteCustomRecipe, getFavoriteIds, setFavoriteId } from './src/utils/storageUtils'; import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from './src/utils/defaultRecipes'; import { getCurrentGPS, requestLocationPermissions, reverseGeocode, withDeadline } from './src/utils/locationUtils'; // Types are erased at build time, so this one is free. import type { PickedPhotoAsset } from './src/utils/photoGps'; import { PARAM_DEFS } from './src/utils/paramDefs'; import { LITE_FAVORITE_LIMIT, LITE_MARK, LITE_RECIPE_LIMIT, proLookInUse } from './src/utils/entitlement'; import { initProUnlock, useProUnlock } from './src/utils/proUnlock'; import UltraWide from './modules/recipescam-ultrawide'; import RecipescamExport from './modules/recipescam-export'; import VolumeShutter from './modules/recipescam-volume'; // @ts-ignore import './global.css'; // Shallow value diff over the union of both objects' keys: undefined and a // missing key both read as "no value", so an old session without the newer // optional knobs is not reported as an edit. const differs = (a: T, b: T) => { const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set; return [...keys].some((k) => (a[k] ?? null) !== (b[k] ?? null)); }; const SESSION_KEY = 'saved_session'; const STARTUP_MODE_KEY = 'startup_mode'; // Camera/device settings that survive restarts regardless of startup mode // (the session file only restores the full look under 'last session'). const CAMERA_SETTINGS_KEY = 'camera_settings'; // P5: native Kotlin export path. EXPO_PUBLIC_NATIVE_EXPORT=1 swaps the Skia // render engine for the RecipesCamExport native pipeline (same look contract, // rendering off the JS thread). Default stays legacy until P6 parity/perf gate // decides which engine ships. const NATIVE_EXPORT = process.env.EXPO_PUBLIC_NATIVE_EXPORT === '1'; // B1: the shutter's CameraX capture mode. 'balanced' ships as the default — // measured on the 12S Ultra at the same 3000x4000, a frame takes ~340 ms // against ~540 ms for 'quality', with no resolution loss, because this device // has ZSL (supportsSpeedQualityPrioritization) and 'balanced'/speed ride it. // EXPO_PUBLIC_CAPTURE_MODE still overrides, but ONLY in dev bundles: Metro // inlines EXPO_PUBLIC_*, the release HBC build does not (B0 finding) — never // gate shipped behaviour on it. // How long a shutter may wait for a GPS fix before exporting without it. The // photo is never held hostage by the receiver: indoors a Balanced fix takes // 20s+ (measured 25s on the V2430), which is the whole reason a capture used to // take half a minute to reach the gallery. The next shot picks the fix up. const GPS_WAIT_MS = 2000; // The photo pipeline (Skia, the frame pack, the filter maths) and the recipe // XML codec run behind a shutter press or a share sheet, never on the first // frame, so they are loaded on demand instead of sitting in the cold-start // module graph. Same two functions the eager imports used to bind; wait: only // the *renderers* moved — nothing about an export changed. const loadRenderer = (native: boolean) => native ? import('./src/utils/nativeExport').then((m) => m.processAndExportPhotoNative) : import('./src/utils/exportEngine').then((m) => m.processAndExportPhoto); const CAPTURE_MODE = process.env.EXPO_PUBLIC_CAPTURE_MODE === 'speed' || process.env.EXPO_PUBLIC_CAPTURE_MODE === 'quality' ? process.env.EXPO_PUBLIC_CAPTURE_MODE : 'balanced'; export default function App() { const [mode, setMode] = useState<'camera' | 'library'>('camera'); // Back/front lens the live viewfinder uses; flipping is transient UI state // (not persisted in sessions — startup always opens on the back camera). const [cameraFacing, setCameraFacing] = useState<'back' | 'front'>('back'); // Imperative handle into the Viewfinder camera controller: reads the live // viewfinder state at capture/export time (framed-window zoom, crop rect, // horizon). There is no EV flush to do: the hardware AE bias is never // touched — setExposureBias re-locks AE and flashes the Xiaomi preview. const viewfinderRef = useRef(null); // Edge-to-edge (app.json/gradle edgeToEdgeEnabled) makes Android ignore the // manifest's adjustResize, so the OS never shrinks the window for the IME and // the bottom deck stays under the keyboard. Track the IME height ourselves and // lift the deck by it (covers the custom-watermark text input). const [keyboardHeight, setKeyboardHeight] = useState(0); // Top edge of the bottom deck (its own onLayout) -> the height left for the // photo. Library mode letterboxes the image above it so no watermark // position hides under an open panel. Null until the first layout pass. const [deckTop, setDeckTop] = useState(null); // Bottom edge of the floating top strip -> the library photo starts there; a // ratio band peeking under the header is a band the user cannot see. const [topInset, setTopInset] = useState(0); // Library photo rotation (FRAME tab): the quarter turn in degrees clockwise // plus the fine STRAIGHTEN offset. Both feed the preview source and the // export alike, the straighten angle applied on top of the turn. const [photoRotation, setPhotoRotation] = useState<0 | 90 | 180 | 270>(0); const [photoStraighten, setPhotoStraighten] = useState(0); useEffect(() => { const show = Keyboard.addListener('keyboardDidShow', (e) => setKeyboardHeight(e.endCoordinates.height)); const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardHeight(0)); return () => { show.remove(); hide.remove(); }; }, []); const [recipes, setRecipes] = useState([]); // LITE (free) vs PRO: the PRO apk is born owned, the free one buys the tier // through Google Play. Runtime state, read here and passed down. const pro = useProUnlock().owned; // The paywall sheet: opened by the SETTINGS plan row and by every alert that // names a PRO feature, so the refusal always comes with a way to pay. const [showPro, setShowPro] = useState(false); // Ask the store what this account owns, once, at startup. Until it answers, // the cached answer stands (see src/utils/proUnlock.ts). useEffect(() => { initProUnlock(); }, []); // Lite stores up to LITE_RECIPE_LIMIT recipes of its own; past that the save // paths ask for PRO. Deleting one is PRO as well, so a Lite list only grows. const customRecipeCount = recipes.filter((r) => r.isCustom).length; const liteSaveBlocked = !pro && customRecipeCount >= LITE_RECIPE_LIMIT; const promptRecipeLimit = () => promptProFeature(`Saving more than ${LITE_RECIPE_LIMIT} recipes`); // FAVORITED: ids of the starred recipes. Metadata only — never saved into the // session look, so RESET and applying a recipe leave it untouched. const [favoriteIds, setFavoriteIds] = useState([]); const [selectedRecipe, setSelectedRecipe] = useState(null); // Custom recipe open in the parameter editor (the CREATE form in edit mode). const [editingRecipe, setEditingRecipe] = useState(null); // Custom temporary overrides for the sliders const [adjustments, setAdjustments] = useState({ ...DEFAULT_ADJUSTMENTS }); const [selectedFrame, setSelectedFrame] = useState('none'); // PRO only: the GPS stamp is a paid look, so a LITE session starts without // it and `handleToggleGeotag` refuses to turn it on. const [useGeotag, setUseGeotag] = useState(pro); // GPS master switch (SETTINGS -> GPS): may the app read the device's // location at all? ON resolves a fix at capture and reverse-geocodes it; // OFF reads nothing. The GPS WATERMARK chip is separate — it only decides // whether a resolved location is printed on the photo. const [gpsEnabled, setGpsEnabled] = useState(true); const [gpsInfo, setGpsInfo] = useState(null); // Custom text watermark (WATERMARK tab -> CUSTOM WATERMARK chip): enabled + // text + drag position as 0..1 fractions of the photo area. Lives here so the // live Viewfinder overlay, the panel input and every export share one state. const DEFAULT_CUSTOM_WM: CustomWatermark = { enabled: false, text: '', x: 0.5, y: 0.5 }; const [customWm, setCustomWm] = useState({ ...DEFAULT_CUSTOM_WM }); // WATERMARK tab ROTATE chip: clockwise degrees, applied to the GPS lines and // the custom text in both the preview and the exported file. const [wmRotation, setWmRotation] = useState(0); // GPS mark placement (WATERMARK tab drag + pinch): fractions of the frame / // photo area plus a size multiplier, same space as the custom mark. There is // nothing to accept — the panel being open IS the edit session. const DEFAULT_GPS_WM: GpsWatermark = { x: 0.05, y: 0.1, size: 1, color: '#f59e0b', showName: true, showTime: true, }; const [gpsWm, setGpsWm] = useState({ ...DEFAULT_GPS_WM }); // FRAME tab ORIENTATION chip (WALL FRAME only): hang the artwork landscape // (the PNG's own 4000x3117) instead of the default portrait rotation. const [wallLandscape, setWallLandscape] = useState(false); // The mark the EXPORT burns in. The chip above is the live draft (typing + // dragging update it); only ACCEPT in the WATERMARK tab copies the draft in // here — until then the file keeps the previous stamp (or none). const [customWmApplied, setCustomWmApplied] = useState({ ...DEFAULT_CUSTOM_WM, }); // Bumped by a double-tap on the mark: the panel re-focuses the text input. const [wmEditNonce, setWmEditNonce] = useState(0); // Draft edit: toggling the chip OFF also drops any accepted stamp, so a // switched-off watermark can never come back in an export. const handleUpdateCustomWm = (wm: CustomWatermark) => { // Typing coalesces into one step per pause; a chip (colour, size, ON/OFF) // is a step of its own even when two of them land back to back. commit(wm.text !== customWm.text, () => { setCustomWm(wm); if (!wm.enabled) setCustomWmApplied({ ...DEFAULT_CUSTOM_WM }); }); }; // Leaving the watermark editor = closing its panel. The mark keeps capturing // image touches ONLY while that panel is open (Viewfinder's wmArmed), so this // is what hands the photo back to tap-to-focus / pinch / double-tap zoom. const closeWatermarkEditor = () => { setOpenTab(null); setOpenParamKey(null); }; // ACCEPT: the draft becomes the mark the export burns in, then the editor // closes so the image stops being a placement surface. const handleAcceptCustomWm = () => { commit(false, () => setCustomWmApplied({ ...customWm })); closeWatermarkEditor(); }; // CANCEL: drop every un-accepted edit and fall back to the last ACCEPTED mark // (none at all when nothing was ever accepted), then close exactly like // ACCEPT — the escape hatch a running session previously lacked. const handleCancelCustomWm = () => { commit(false, () => setCustomWm({ ...customWmApplied })); closeWatermarkEditor(); }; // Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar. const [aspectRatio, setAspectRatio] = useState('full'); // FRAME-tab CROP (library plain frames). 'free' keeps an explicit rectangle // the Viewfinder lets the user drag; fixed ratios center-crop. Both are // handed to the export so the file matches the preview. const [cropRatio, setCropRatio] = useState('none'); // CROP is a two-step tool: the ratio strip only FRAMES the band (amber // outline + dim surround) until APPLY commits it, after which the viewer // shows the crop alone. Holds the committed ratio, so picking another ratio // (or RESET) drops straight back to the framing state. const [cropApplied, setCropApplied] = useState(null); const [cropRect, setCropRect] = useState(DEFAULT_CROP_RECT); const [rawEnabled, setRawEnabled] = useState(false); // Full-frame metering mode (FOCUS → METERING MODE): highlight-weighted meters // the BRIGHTEST area to protect its detail (never blow whites) and pushes the // shadows very deep — extreme contrast (GR/Sony). Realised as: a small EV dip // (capture headroom; NOT a full-frame dim), a highlight roll (keep bright // detail), deep shadow crush and raised contrast/clarity. All constants are a // starting approximation — tune after device testing. const [meterMode, setMeterMode] = useState('evaluative'); const METER_HL_EV = -0.5; const METER_HL_HIGHLIGHT = -3; const METER_HL_SHADOW = -4; const METER_HL_CONTRAST = 3; const METER_HL_CLARITY = 3; const clampAdj = (v: number) => Math.max(-10, Math.min(10, v)); const meteringActive = mode === 'camera' && meterMode === 'highlight'; // The overlay is built FROM the adjustments it is laid on, so the preview can // ask for the pre-edit one while the finger holds the compare. Built from the // live values it always was, it kept painting the EDITED look back over the // recipe the peek had swapped in, and neither HIGHLIGHT nor SHADOW (nor EV, // CONTRAST, CLARITY) ever came back. const meteringOverlay = (a: ColorAdjustments): Partial => ({ exposureCompensation: clampAdj((a.exposureCompensation ?? 0) + METER_HL_EV), highlight: clampAdj((a.highlight ?? 0) + METER_HL_HIGHLIGHT), shadow: clampAdj((a.shadow ?? 0) + METER_HL_SHADOW), contrast: clampAdj((a.contrast ?? 0) + METER_HL_CONTRAST), clarity: clampAdj((a.clarity ?? 0) + METER_HL_CLARITY), }); const meteringAdj: Partial | undefined = meteringActive ? meteringOverlay(adjustments) : undefined; // Same overlay for the camera EXPORT — the EV dip included: EV is software // only (no hardware AE bias anywhere), so preview and file must both apply it. const renderAdj: ColorAdjustments = meteringAdj ? { ...adjustments, ...meteringAdj } : adjustments; const rawFailRef = useRef(false); // Progressive disclosure: a tool chip opens its panel; null = photo hero only. const [openTab, setOpenTab] = useState(null); // The parameter whose slider row is open inside the panel — lifted here (not // local to AdjustmentPanel) because an open row arms drag-anywhere-on-the- // image adjustment in the Viewfinder. Cleared when the panel/tab closes. const [openParamKey, setOpenParamKey] = useState(null); // FRAME → WATERMARK sub-panel is open (the rail's WATERMARK tab is gone). const [wmPanelOpen, setWmPanelOpen] = useState(false); const [libraryImageUri, setLibraryImageUri] = useState(null); // Session photo history (camera captures + library exports). The camera // thumbnail/preview button shows the newest entry; the swipe viewer walks the // whole list. Not persisted across restarts — entries are app-cache files. const [photoHistory, setPhotoHistory] = useState([]); // Index into photoHistory of the photo shown in the swipe viewer; null = closed. const [viewerIndex, setViewerIndex] = useState(null); // In-app library picker (grid -> full-screen preview -> USE/CANCEL). const [libraryPickerVisible, setLibraryPickerVisible] = useState(false); // Gear sheet (sound / ratio / metering mode). Camera captures never auto-open // the viewer anymore — the thumbnail button does. const [settingsVisible, setSettingsVisible] = useState(false); // Shutter click: only OFF/ON (no volume API on Android). Applied at capture. const [shutterSound, setShutterSound] = useState('on'); // LED flash mode. Capture-time only (no live torch): 'off' keeps today's // behaviour, 'auto' lets the pipeline decide, 'on' always fires. Applied to // the still, so the preview itself never changes brightness. const [flashMode, setFlashMode] = useState('off'); // Touch-to-shutter (gear sheet): a plain tap on the live viewfinder locks // AE/AF/AWB on the tapped point and, once focus has settled, fires a capture. // OFF = a tap only focuses (the historical behaviour). const [touchToShutter, setTouchToShutter] = useState(false); // Startup behavior: 'default' = PROVIPES neutral at 4:3 camera; 'last' = restore // previous session from AsyncStorage. Default is 'default'. const [startupMode, setStartupMode] = useState('default'); const photoSeq = useRef(0); const [isProcessing, setIsProcessing] = useState(false); // Short shutter-burst lock: guards the capturePhotoToFile calls from // overlapping. Released as soon as the frames are written — NOT after the // render, so a new shot can start while the previous photo is still being // processed in the background. const captureBusyRef = useRef(false); // Cold-start shutter gate: the Viewfinder reports when the CameraX session // is actually bound (onStarted/onStopped). A shutter press landing before // that is QUEUED, not dropped — the built-in camera answers the first tap // too, and a press into a dead session produced no file at all. const cameraReadyRef = useRef(false); const pendingShotRef = useRef(false); // FIFO background render queue: processAndExportPhoto (Skia render + gallery // save, ~1s) runs OFF the shutter path, one at a time, so the next photo can // be taken immediately. FIFO keeps history order == shutter order. Renders // are heavy on the JS thread but yield between awaits — UI keeps responding. const exportQueueRef = useRef>(Promise.resolve()); const enqueueExport = (task: () => Promise) => { exportQueueRef.current = exportQueueRef.current .catch(() => {}) // a failed render must not stall the queue .then(task) .catch((e) => console.error('Background export failed:', e)); }; // Hardware Permissions (vision-camera) const { hasPermission: cameraPermissionGranted, requestPermission: requestCameraPermission } = useCameraPermission(); // The lens the flash control belongs to. CameraX throws when asked for // 'on'/'auto' on a device with no flash unit (the front lens, most tablets), // so the control is only offered while this reads true. const flashAvailable = useCameraDevice(cameraFacing)?.hasFlash ?? false; // Full-resolution photo output — attached to the live in Viewfinder. // Photo capture is resolution-independent from the Skia preview output. const photoOutput = usePhotoOutput({ qualityPrioritization: CAPTURE_MODE, }); // Second photo output for RAW DNG sidecars (RATIO panel → RAW on). Kept as // a live output only while rawEnabled — CameraX cannot bind two ImageCapture // use cases, so attaching both at once would break the session. Attaching a // DNG output while RAW is on gives ~one exposure per capture: the JPEG below // and this DNG are separate shots taken a few tens of ms apart. const rawPhotoOutput = usePhotoOutput({ containerFormat: 'dng', 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}`); // Resolved by the mount effect just below, awaited by the share intake further // down: a share that cold-starts the app must land AFTER the session restore, // or the restored libraryImageUri/mode would replace the shared photo — and by // then the intent is consumed, so the photo is unreachable for good. const sessionInitRef = useRef | null>(null); useEffect(() => { // B5: run a single sequenced init — restore the session first, then fall // back to the first recipe only when no recipe was restored. // The restored look is step zero: BACK can never walk behind the load. sessionInitRef.current = initSession().then(clearHistory); // Do NOT auto-load device GPS here — it would override photo GPS on library mode }, []); // A photo picked while the GPS chip was OFF kept its coordinates but no place // name (the geocode only ran when the chip was already on), and toggleGeotag // skips GPS loading entirely when coordinates exist — so turning the chip on // showed "STREET VIEW". Resolve the name whenever the mark needs one. useEffect(() => { if (!gpsEnabled || !gpsInfo || gpsInfo.locality) return; let stale = false; reverseGeocode(gpsInfo.latitude, gpsInfo.longitude).then((name) => { if (!stale && name) setGpsInfo((g) => (g && !g.locality ? { ...g, locality: name } : g)); }); return () => { stale = true; }; }, [gpsEnabled, gpsInfo]); // Warm the fix up at launch instead of at the shutter. A fix taken here is // what the first photo stamps, so the capture path normally has nothing to // wait for — the same head start the built-in camera gets from its own // always-on location client. GPS_WAIT_MS below is the backstop when it is not // ready yet. useEffect(() => { if (!gpsEnabled) return; let stale = false; getCurrentGPS().then((info) => { if (!stale && info) setGpsInfo((g) => g ?? { ...info, source: 'device' as const }); }); return () => { stale = true; }; }, [gpsEnabled]); // 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, aspectRatio, cropRatio, cropRect, cropApplied, meterMode, shutterSound, selectedRecipeId: selectedRecipe?.id || null, gpsInfo, customWm, gpsWm, }; 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, customWm, gpsWm, aspectRatio, cropRatio, cropRect, cropApplied, meterMode, shutterSound]); // Guards the camera-settings persist effect below: until initSession has // read the stored values, changes are just the initial defaults — writing // them would overwrite the saved settings (async restore loses the race // against the mount effect). const settingsHydratedRef = useRef(false); const initSession = async () => { const list = await getAllRecipes(); setRecipes(list); setFavoriteIds(await getFavoriteIds()); // Device/camera settings restore first: they apply under BOTH startup // modes ('default' resets only the look, not these hardware choices). try { const raw = await AsyncStorage.getItem(CAMERA_SETTINGS_KEY); if (raw) { const s = JSON.parse(raw); if (s.shutterSound === 'on' || s.shutterSound === 'off') setShutterSound(s.shutterSound); if (s.flashMode === 'off' || s.flashMode === 'auto' || s.flashMode === 'on') setFlashMode(s.flashMode); if (s.meterMode === 'evaluative' || s.meterMode === 'highlight') setMeterMode(s.meterMode); if (typeof s.rawEnabled === 'boolean') setRawEnabled(s.rawEnabled); if (typeof s.gpsEnabled === 'boolean') setGpsEnabled(s.gpsEnabled); if (typeof s.touchToShutter === 'boolean') setTouchToShutter(s.touchToShutter); } } catch (e) { console.warn('Failed to read camera settings:', e); } settingsHydratedRef.current = true; // Startup mode: 'default' opens camera + PROVIPES film sim (neutral // adjustments, DEFAULT_ADJUSTMENTS) at 4:3. 'last' restores the previous // session from AsyncStorage; with no saved session it falls back to the // same clean default state. let startup: StartupMode = 'default'; try { const raw = await AsyncStorage.getItem(STARTUP_MODE_KEY); if (raw === 'last') startup = 'last'; } catch (e) { console.warn('Failed to read startup mode:', e); } setStartupMode(startup); const applyDefaultStartup = () => { setMode('camera'); setLibraryImageUri(null); setAspectRatio('4:3'); const provia = FILM_SIMS.find((f) => f.id === 'sim-provia'); if (provia) applyRecipe(filmSimToRecipe(provia)); // Clean camera start: GPS watermark stays OFF until the user opts in. setUseGeotag(false); }; if (startup === 'default') { applyDefaultStartup(); return; } 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; } } // A saved 'camera' mode is a deliberate choice and outranks a lingering // library image: switching modes never clears libraryImageUri, so without // this a session closed on the live camera came back up in the viewer. if (imageUri && session.mode !== 'camera') { setLibraryImageUri(imageUri); setMode('library'); } else { // No usable photo (or the user had chosen the camera): open the camera. setMode('camera'); } if (session.adjustments) setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...session.adjustments }); if (session.selectedFrame) setSelectedFrame(session.selectedFrame); if (session.useGeotag !== undefined) setUseGeotag(pro && session.useGeotag); if (session.aspectRatio === 'full' || session.aspectRatio === '4:3' || session.aspectRatio === '3:2') setAspectRatio(session.aspectRatio); if (session.cropRatio) setCropRatio(session.cropRatio); if (session.cropRect && typeof session.cropRect === 'object') setCropRect({ ...DEFAULT_CROP_RECT, ...session.cropRect }); // Committed crop: only trust a value that still matches the restored // ratio, so a stale snapshot can never show a crop it never framed. if (session.cropApplied && session.cropApplied === session.cropRatio) setCropApplied(session.cropApplied); // NOTE: meterMode/shutterSound/rawEnabled are NOT restored from the // session snapshot here — the session file is only written on background, // so it can be stale after a force-kill. CAMERA_SETTINGS_KEY above // persists them on every change and is authoritative for these three. if (session.gpsInfo) setGpsInfo(session.gpsInfo); if (session.customWm && typeof session.customWm === 'object') { // Restore defensively: a session saved by an older build may miss the // field entirely or hold a malformed shape. const raw = session.customWm as Partial; const clamp01 = (v: unknown, fb: number) => typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : fb; setCustomWm({ enabled: raw.enabled === true, text: typeof raw.text === 'string' ? raw.text : '', x: clamp01(raw.x, 0.5), y: clamp01(raw.y, 0.5), color: typeof raw.color === 'string' ? raw.color : undefined, size: typeof raw.size === 'number' && Number.isFinite(raw.size) ? raw.size : undefined, font: pro && typeof raw.font === 'string' ? raw.font : null, }); } if (session.gpsWm && typeof session.gpsWm === 'object') { // Style chips only — the placement fractions are left alone (restoring // a stale x/y would silently move the stamp the user just dragged). const raw = session.gpsWm as Partial; const keep = (v: unknown, fb: boolean) => (typeof v === 'boolean' ? v : fb); setGpsWm((g) => ({ ...g, color: typeof raw.color === 'string' ? raw.color : g.color, showName: keep(raw.showName, true), showTime: keep(raw.showTime, true), })); } if (session.selectedRecipeId) { // Film sims are never persisted in the recipe list (sims applied on the // fly) — resolve sim ids via FILM_SIMS, real recipes via the list. const sim = FILM_SIMS.find((f) => f.id === session.selectedRecipeId); restoredRecipe = sim ? filmSimToRecipe(sim) : list.find((r: Recipe) => r.id === session.selectedRecipeId) || null; } } if (restoredRecipe) { // Recipe identity restored; the look (adjustments/frame/geotag/ratio) // stays exactly as saved in the session above — applyRecipe would reset // them to the recipe's canonical values and drop the user's tweaks. setSelectedRecipe(restoredRecipe); } else { applyDefaultStartup(); } }; const loadGPS = async () => { const info = await getCurrentGPS(); if (info) { setGpsInfo({ ...info, source: 'device' }); } }; const applyRecipe = (recipe: Recipe) => { commit(false, () => { setSelectedRecipe(recipe); // EVERY knob the recipe carries lands in the panel, backfilled over the // defaults so a recipe saved before a knob existed cannot leave that knob // on whatever the previous look happened to have. setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...recipe.adjustments }); setSelectedFrame(recipe.frameId || 'none'); }); // GPS watermark is the user's own setting (FRAME tab), not part of a // preset look: recipes carry a useGeotag field but selecting one must // never flip the watermark on/off. }; // Picking a look — sim or saved recipe — loads the whole thing: identity, // every parameter and the frame it was saved with. The panel then shows the // values that are actually on screen (and stays editable from there). This // used to keep the sliders for sims, which left the panel naming numbers the // look no longer had: the sim carried its own adjustments and they were // silently dropped. The session restore below is a different case — there the // stored adjustments ARE the user's, so it still sets the recipe alone. const handleSelectRecipe = (recipe: Recipe) => { applyRecipe(recipe); }; // The recipe exactly as the user picked it. `selectedRecipe` is NOT a safe // copy of it: every slider move rewrites its `adjustments` in place // (handleUpdateAdjustments), so the canonical values only survive in the // film-sim table and in the saved list. const pristineRecipe = (recipe: Recipe): Recipe | null => { const sim = FILM_SIMS.find((f) => f.id === recipe.id); if (sim) return filmSimToRecipe(sim); return recipes.find((r) => r.id === recipe.id) || null; }; // Every edit belongs to the mode and the photo it was made on, so it never // crosses into the next session: the sliders go back to the recipe's own // values, a frame picked for that still is dropped, and the custom mark — // placed against that very photo — is switched back off. const resetEditsToRecipe = () => { const base = selectedRecipe ? pristineRecipe(selectedRecipe) : null; if (base) { applyRecipe(base); } else { setAdjustments({ ...DEFAULT_ADJUSTMENTS }); setSelectedFrame('none'); } setCustomWm({ ...DEFAULT_CUSTOM_WM }); setCustomWmApplied({ ...DEFAULT_CUSTOM_WM }); setGpsWm({ ...DEFAULT_GPS_WM }); // A look belongs to the photo and the mode it was edited on: the next load // starts a fresh history, even when the values come back the same. clearHistory(); }; // RESET (panel chip, any tab): the state a default start would leave — the // startup look, every parameter back to neutral, frame off and both // watermarks back to their defaults. Same pieces as resetEditsToRecipe, but // the look goes back to the app's own default rather than the recipe's. // A RESET on an already-clean look is not a step: BACK would have nothing // to put back and would burn a press on a no-op. const handleResetAll = () => { if (resetDirty) commit(false, resetLookInPlace); }; const resetLookInPlace = () => { const provia = FILM_SIMS.find((f) => f.id === 'sim-provia'); if (provia) { applyRecipe(filmSimToRecipe(provia)); } else { setSelectedRecipe(null); setAdjustments({ ...DEFAULT_ADJUSTMENTS }); setSelectedFrame('none'); } setCustomWm({ ...DEFAULT_CUSTOM_WM }); setCustomWmApplied({ ...DEFAULT_CUSTOM_WM }); setGpsWm({ ...DEFAULT_GPS_WM }); // The rest of the look's state, each back to the value a clean start ships // with: the GPS stamp off, both watermark rotations upright, the library // photo and its wall frame back to portrait. ponytail: mode + aspect stay // put — they frame the shot you are working on, not the look. setUseGeotag(false); setWmRotation(0); setPhotoRotation(0); setPhotoStraighten(0); setWallLandscape(false); setCropRatio('none'); setCropApplied(null); setCropRect(DEFAULT_CROP_RECT); }; // RESET lights up while the look differs from what handleResetAll puts back — // every clause here has a matching setter there, or the star never goes out. // The clean-start values: the startup sim, neutral knobs, no frame, no crop, // both marks at their defaults, GPS stamp off, nothing rotated. const resetDirty = (selectedRecipe?.id ?? 'sim-provia') !== 'sim-provia' || differs(adjustments, DEFAULT_ADJUSTMENTS) || selectedFrame !== 'none' || differs(customWm, DEFAULT_CUSTOM_WM) || differs(customWmApplied, DEFAULT_CUSTOM_WM) || differs(gpsWm, DEFAULT_GPS_WM) || useGeotag || wmRotation !== 0 || photoRotation !== 0 || photoStraighten !== 0 || wallLandscape || cropRatio !== 'none' || differs(cropRect, DEFAULT_CROP_RECT); // ---- Look history: BACK (undo) + PEEK (hold to compare) ------------------ // Every edit the panel can make is a step: PRESETS/RECIPES, LIGHT, WB, FX and // FRAME (frame, crop, turn, straighten, both watermarks, the GPS stamp). BACK // pops one step; PEEK — a hold on the picture — shows the // look one step back, with every tab's values travelling with it. Neither // touches the stored values, the RECIPE or the export: undo writes them, peek // only shows them. The history is seeded at every load, so BACK stops there. type Look = { recipe: Recipe | null; adjustments: ColorAdjustments; frame: FrameId; cropRatio: CropRatio; cropApplied: CropRatio | null; cropRect: CropRect; photoRotation: 0 | 90 | 180 | 270; photoStraighten: number; wallLandscape: boolean; useGeotag: boolean; wmRotation: number; customWm: CustomWatermark; customWmApplied: CustomWatermark; gpsWm: GpsWatermark; }; const lookNow = (): Look => ({ recipe: selectedRecipe, adjustments, frame: selectedFrame, cropRatio, cropApplied, cropRect, photoRotation, photoStraighten, wallLandscape, useGeotag, wmRotation, customWm, customWmApplied, gpsWm, }); const lookRef = useRef(null); const pastRef = useRef([]); const [undoDepth, setUndoDepth] = useState(0); const [peekLook, setPeekLook] = useState(null); // A gesture is one step: a slider drag (or a run of keystrokes) inside this // gap stays the step it started as, so BACK undoes the whole gesture rather // than one event of it. const PEEK_GAP_MS = 700; const lastStepRef = useRef(0); // A step is pushed BEFORE the change lands. Nested commits (a handler whose // work calls another committing one, e.g. RESET -> applyRecipe) belong to the // outer step, or BACK would need one press per layer of one press. const commitDepthRef = useRef(0); const commit = (coalesce: boolean, mutate: () => void) => { if (commitDepthRef.current > 0) { mutate(); return; } const now = Date.now(); if (!coalesce || now - lastStepRef.current > PEEK_GAP_MS) { if (lookRef.current) pastRef.current.push(lookRef.current); setUndoDepth(pastRef.current.length); } lastStepRef.current = coalesce ? now : 0; commitDepthRef.current++; try { mutate(); } finally { commitDepthRef.current--; } }; // The loaded look is step zero — nothing before it can be undone. const clearHistory = () => { pastRef.current = []; lastStepRef.current = 0; setUndoDepth(0); }; const applyLook = (l: Look) => { setSelectedRecipe(l.recipe); setAdjustments(l.adjustments); setSelectedFrame(l.frame); setCropRatio(l.cropRatio); setCropApplied(l.cropApplied); setCropRect(l.cropRect); setPhotoRotation(l.photoRotation); setPhotoStraighten(l.photoStraighten); setWallLandscape(l.wallLandscape); setUseGeotag(l.useGeotag); setWmRotation(l.wmRotation); setCustomWm(l.customWm); setCustomWmApplied(l.customWmApplied); setGpsWm(l.gpsWm); }; const handleUndo = () => { if (!pro) { promptProFeature('Undo'); return; } const prev = pastRef.current.pop(); if (!prev) return; setPeekLook(null); lastStepRef.current = 0; setUndoDepth(pastRef.current.length); applyLook(prev); }; const handlePeek = (on: boolean) => { if (!on) { setPeekLook(null); return; } const before = pastRef.current[pastRef.current.length - 1]; setPeekLook(before ?? null); }; // PEEK follows the finger on the picture (the chip is gone). Armed only when // there is a step to compare against, so a long press to focus cannot turn // into a paywall in a LITE build. const peekArmed = pro && undoDepth > 0; // The live look, mirrored every render so a handler can snapshot what was on // screen just before it changes it. const liveLook = lookNow(); lookRef.current = liveLook; // The look the panel and the picture SHOW. While the finger is down on PEEK // (or on the picture) every number and every tab travels back a step, so the // before/after is readable, not just visible. The real values stay untouched: // writes, RECIPE and the on-image drag baseline all read the live ones. const shown = peekLook ?? liveLook; const shownAdjustments = shown.adjustments; const peeking = peekLook !== null; // What the preview draws: the shown recipe's own chrome (base filter, name) // rebuilt over the shown values, so a peek that steps over a PRESETS/RECIPES // change brings that look's filter back with it. A camera start carries no // recipe, so that stays the synthetic one. const previewRecipe: Recipe = { id: 'temp', name: 'Custom', baseFilter: 'none', ...(shown.recipe ?? {}), adjustments: shown.adjustments, frameId: shown.frame, useGeotag: shown.useGeotag, }; const handleUpdateAdjustments = (updates: Partial) => { // Storm guard, generic across every param: same-value writes (slider // micro-jitter / repeated pointer-move events) still build a fresh // adjustments object and re-render the whole tree — skip them. Supersedes // the old EV-only dedupe; EV flush at capture reads its own ref, so a // skipped identical EV needs no re-send. const keys = Object.keys(updates) as (keyof ColorAdjustments)[]; if (keys.every((k) => updates[k] === adjustments[k])) return; // B8: never call a setState inside another setState's updater. const next = { ...adjustments, ...updates }; // One drag = one step (see commit's coalescing): the step holds the values // the drag started from, so BACK and PEEK land on the drag's own start. // ponytail: a >700ms stall in the middle of one drag splits it in two. commit(true, () => { setAdjustments(next); if (selectedRecipe) { setSelectedRecipe({ ...selectedRecipe, adjustments: next, }); } }); }; const handleUpdateFrame = (frameId: FrameId) => commit(false, () => applyFrame(frameId)); const applyFrame = (frameId: FrameId) => { setSelectedFrame(frameId); // CROP is a plain-frame tool (polaroid/wall crop through their own window). // Keep the crop out of a frame that ignores it, or the ratio survives in the // strip and lights the CROP chip on a frame the export never crops. if (frameId !== 'none' && frameId !== 'classic-white' && frameId !== 'cinematic') { setCropRatio('none'); setCropApplied(null); setCropRect(DEFAULT_CROP_RECT); } if (selectedRecipe) { setSelectedRecipe({ ...selectedRecipe, frameId, }); } }; // SETTINGS -> GPS. ON asks for the permission once and resolves a fix // now, so the coordinates are already there when the shutter fires. const handleToggleGps = async (enabled: boolean) => { if (!enabled) { setGpsEnabled(false); // Only the device fix goes: a picked photo keeps its own EXIF location. setGpsInfo((g) => (g && g.source === 'device' ? null : g)); return; } if (!(await requestLocationPermissions())) { Alert.alert('Permission Denied', 'Enable location services to use GPS.'); return; } setGpsEnabled(true); loadGPS(); }; const handleToggleGeotag = async (enabled: boolean) => { if (enabled && !pro) { promptProFeature('The GPS watermark'); return; } commit(false, () => { setUseGeotag(enabled); if (selectedRecipe) { setSelectedRecipe({ ...selectedRecipe, useGeotag: enabled, }); } }); if (enabled && !gpsInfo) { const locationGranted = await requestLocationPermissions(); if (locationGranted) { setGpsEnabled(true); loadGPS(); } else { Alert.alert('Permission Denied', 'Enable location services to use GPS Watermark.'); setUseGeotag(false); } } }; const handleAspectRatio = (ratio: AspectRatio) => { setAspectRatio(ratio); }; const handleRawEnabled = (enabled: boolean) => { rawFailRef.current = false; // allow one recovery alert per enable setRawEnabled(enabled); if (enabled) { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); } }; const handleStartupMode = (mode: StartupMode) => { setStartupMode(mode); AsyncStorage.setItem(STARTUP_MODE_KEY, mode).catch(() => {}); }; // Persist device/camera settings on every change — not just on background — // so they survive a kill/relaunch even under 'default' startup, which only // resets the look (mode/ratio/recipe/geotag), not these. useEffect(() => { if (!settingsHydratedRef.current) return; AsyncStorage.setItem( CAMERA_SETTINGS_KEY, JSON.stringify({ shutterSound, flashMode, meterMode, rawEnabled, gpsEnabled, touchToShutter }) ).catch(() => {}); }, [shutterSound, flashMode, meterMode, rawEnabled, gpsEnabled, touchToShutter]); // Latest-rawEnabled ref so the stable session-error callback below can read // the live toggle without re-subscribing the Camera session on every render. const rawEnabledRef = useRef(rawEnabled); rawEnabledRef.current = rawEnabled; // Attaching the DNG output while RAW is on adds a second ImageCapture use // case — CameraX historically refuses two per camera. If the session errors // in that state, drop RAW (removing the output reconfigures back to the // single working JPEG session) instead of leaving a dead camera. const handleCameraSessionError = useCallback((error: Error) => { console.error('Camera session error:', error); if (rawEnabledRef.current && !rawFailRef.current) { rawFailRef.current = true; setRawEnabled(false); Alert.alert( 'RAW Unavailable', 'DNG capture could not start on this device, so RAW was turned off. JPEG capture keeps working.' ); } }, []); // Save the freshly captured DNG into an album literally named "RAW" (create // the album on first use). Failure never blocks the JPEG pipeline. const saveRawToAlbum = async (uri: string): Promise => { try { const perm = await MediaLibraryLegacy.requestPermissionsAsync(); if (!perm.granted) { console.warn('RAW save: media library permission denied'); return false; } const asset = await MediaLibraryLegacy.createAssetAsync(uri); const albumName = 'RAW'; let album = await MediaLibraryLegacy.getAlbumAsync(albumName); if (album) { await MediaLibraryLegacy.addAssetsToAlbumAsync(asset, album); } else { await MediaLibraryLegacy.createAlbumAsync(albumName, asset); } return true; } catch (e) { console.error('RAW album save failed:', e); return false; } }; const handleSaveRecipe = async (name: string) => { if (liteSaveBlocked) { promptRecipeLimit(); return; } 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.`); }; // TopBar SAVE & SHARE -> EXPORT & SHARE: the look on screen as an encrypted // recipe file, handed to the share sheet (and to the phone's storage when // the recipient sheet is used to save it). const handleExportShare = async (name: string) => { try { const { exportRecipeXml } = await import('./src/utils/recipeShare'); const xml = exportRecipeXml({ id: 'share', name, baseFilter: activeRecipe.baseFilter, adjustments, frameId: selectedFrame, useGeotag, }); const file = `${FileSystem.cacheDirectory}${name.replace(/[^\w.-]+/g, '_') || 'recipe'}.rcx.xml`; await FileSystem.writeAsStringAsync(file, xml); if (!(await Sharing.isAvailableAsync())) { Alert.alert('Recipe Exported', `Saved as ${file}`); return; } await Sharing.shareAsync(file, { mimeType: 'application/xml', dialogTitle: `Share recipe "${name}"` }); } catch (e) { Alert.alert('Export failed', e instanceof Error ? e.message : String(e)); } }; // PRESETS -> IMPORT: a recipe file shared from another phone, picked from // storage, decoded and stored as a normal custom recipe. const handleImportRecipe = async () => { if (liteSaveBlocked) { promptRecipeLimit(); return; } try { const picked = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true }); if (picked.canceled) return; const xml = await FileSystem.readAsStringAsync(picked.assets[0].uri); const { importRecipeXml } = await import('./src/utils/recipeShare'); const created = await saveCustomRecipe(importRecipeXml(xml)); setRecipes(await getAllRecipes()); applyRecipe(created); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Alert.alert('Recipe Imported', `"${created.name}" has been added.`); } catch (e) { Alert.alert('Import failed', e instanceof Error ? e.message : String(e)); } }; // TopBar "SAVE" on a user-created recipe: overwrite it in place, same id. const handleOverwriteRecipe = async (name: string) => { if (!selectedRecipe) return; await updateCustomRecipe(selectedRecipe.id, { name, baseFilter: selectedRecipe.baseFilter, adjustments, frameId: selectedFrame, useGeotag, }); const list = await getAllRecipes(); setRecipes(list); const updated = list.find((r) => r.id === selectedRecipe.id); if (updated) applyRecipe(updated); Alert.alert('Recipe Updated', `"${name}" has been saved.`); }; // The recipe editor (the CREATE form in edit mode) SAVE. The typed name is // the switch: unchanged = overwrite that recipe in place (same id, new // parameters), new = keep the original and store the edit as its own recipe. // The editor only closes first, the confirm dialog comes right after. const handleUpdateRecipe = async (recipe: Omit) => { const target = editingRecipe; setEditingRecipe(null); if (!target) return; const name = recipe.name.trim(); const unchanged = name === target.name.trim(); if (!unchanged && liteSaveBlocked) { promptRecipeLimit(); return; } const commit = async () => { if (unchanged) { await updateCustomRecipe(target.id, { ...recipe, name }); const list = await getAllRecipes(); setRecipes(list); const updated = list.find((r) => r.id === target.id); if (updated) applyRecipe(updated); } else { const created = await saveCustomRecipe({ ...recipe, name }); setRecipes(await getAllRecipes()); applyRecipe(created); } Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Alert.alert( unchanged ? 'Recipe Updated' : 'Recipe Saved', `"${name}" has been saved.` ); }; Alert.alert( unchanged ? 'Overwrite recipe?' : 'Save as new recipe?', unchanged ? `Replace "${target.name}" with the edited parameters?` : `Keep "${target.name}" and save the edit as "${name}"?`, [ { text: 'CANCEL', style: 'cancel' }, { text: unchanged ? 'OVERWRITE' : 'SAVE', onPress: () => { commit(); } }, ], ); }; // PRESETS → CREATE quick-input: build a brand-new recipe from the modal's // typed fields, persist it, and apply it (the user sees the look immediately). const handleCreateRecipe = async (recipe: Omit) => { // Lite composes the recipe and applies it as a throwaway draft so the look // can be tried; storing it is what the LITE_RECIPE_LIMIT caps. 'draft' is no // free id, so the shutter gate already treats the look as the PRO preset. if (liteSaveBlocked) { applyRecipe({ ...recipe, id: 'draft', isCustom: false }); promptRecipeLimit(); return; } const newRecipe = await saveCustomRecipe(recipe); const list = await getAllRecipes(); setRecipes(list); applyRecipe(newRecipe); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }; // Deleting is PRO: Lite may create and edit its recipes, it may not drop them. const handleDeleteRecipe = async (id: string) => { if (!pro) { promptProFeature('Deleting recipes'); return; } await deleteCustomRecipe(id); const list = await getAllRecipes(); setRecipes(list); if (selectedRecipe?.id === id && list.length > 0) { applyRecipe(list[0]); } }; // Star/un-star a recipe: optimistic (the chip repaints on tap), persisted in // the background. The write is the only failure worth logging — the look is // untouched either way. const handleToggleFavorite = (id: string) => { const on = !favoriteIds.includes(id); // LITE gets one favorite; PRO is what buys a list. Un-starring is never // gated, so the way back under the limit is always open. if (on && !pro && favoriteIds.length >= LITE_FAVORITE_LIMIT) { promptProFeature(`Favoriting more than ${LITE_FAVORITE_LIMIT} recipe`); return; } setFavoriteIds((ids) => (on ? [...ids, id] : ids.filter((x) => x !== id))); setFavoriteId(id, on).catch((e) => console.warn('Failed to save favorite:', e)); }; // FRAME-tab AUTO: the photo is levelled from its OWN pixels, not from the // phone. A library still carries no record of how the camera was held when // it was shot (EXIF Orientation only knows 0/90/180/270), so the sensor had // nothing to offer; the dominant line in the picture is the only horizon // there is. A one-shot: it replaces whatever the slider or a previous AUTO // left, and does nothing at all when the picture has no line worth trusting. const handleAutoStraighten = useCallback(() => { const tilt = viewfinderRef.current?.detectHorizon?.() ?? null; if (tilt == null) return; commit(false, () => setPhotoStraighten(-tilt)); // commit only reads refs, so the empty dep list stays honest. }, []); // OPEN IMAGE: the in-app picker (LibraryPickerModal) does the choosing — a // grid, then the photo full screen to pinch/double-tap zoom and swipe through, // then USE or CANCEL. The system picker it replaced returned the still on the // first tap (no preview) and, on Android 13+, as a redacted cache copy with // assetId=null — the grid uri keeps the MediaStore asset id, so with // ACCESS_MEDIA_LOCATION granted the file is not redacted and tier-1 EXIF / // tier-2 MediaStore both still work. const handlePickImage = () => { if (isProcessing) return; setLibraryPickerVisible(true); }; const handleLibraryPicked = async (asset: PickedPhotoAsset) => { setLibraryPickerVisible(false); if (!asset.uri) return; setLibraryImageUri(asset.uri); // A new photo starts level: the previous photo's turn and fine // straighten must not carry over. setPhotoRotation(0); setPhotoStraighten(0); setMode('library'); // Picking from the camera is the other half of that switch (the camera // button is the way back), so the camera's edits must not land on the // photo: the picked still starts at the recipe's own values too. if (mode !== 'library') resetEditsToRecipe(); // Clear old GPS — will be replaced if the photo (or device) has location data setGpsInfo(null); // Multi-tier GPS resolution: EXIF → MediaLibrary. Never the device's own // location — that is where the phone is now, not where the photo was // taken, and stamping it just printed the wrong place name on the mark. const { resolvePhotoGPS } = await import('./src/utils/photoGps'); const { gps, permissionDenied } = await resolvePhotoGPS(asset, { useGeotag: gpsEnabled }); setGpsInfo(gps); if (permissionDenied) { console.warn('Media library read permission denied — photo GPS unavailable.'); } Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }; // System share intake. A shared photo arrives as the launch intent's // EXTRA_STREAM, which only the Kotlin module can read (takeSharedImage): it // copies the stream into the cache and this feeds it to the library pipeline // as an ordinary photo — same path as a pick, GPS and all. const sharedPickedRef = useRef(handleLibraryPicked); sharedPickedRef.current = handleLibraryPicked; useEffect(() => { let stale = false; let busy = false; const importShared = async () => { if (stale || busy) return; busy = true; try { await sessionInitRef.current; if (stale) return; const shared = await RecipescamExport.takeSharedImage(); if (stale || !shared?.path) return; sharedPickedRef.current({ uri: `file://${shared.path}`, assetId: shared.assetId || null }); } finally { busy = false; } }; importShared(); // The other half is a share landing on an app that is already alive: no // mount pass sees that intent, only this background -> active transition. // The native read clears the extra, so later resumes import nothing. const sub = AppState.addEventListener('change', (nextAppState) => { if (nextAppState === 'active') importShared(); }); return () => { stale = true; sub.remove(); }; }, []); const activeRecipe: Recipe = selectedRecipe || { id: 'temp', name: 'Custom', baseFilter: 'none', adjustments, frameId: selectedFrame, useGeotag, }; // Thumbnail/preview target for the camera-mode right button: newest photo. const lastPhotoUri = photoHistory.length > 0 ? photoHistory[photoHistory.length - 1].uri : null; // The PRO feature the current look is using, or null while it is Lite-clean. // Only the EXPORT consults it: the preview shows every look for free, so the // answer is computed where the file is about to be written, not on the chips. // PRO unlocks every look, so a paying user has nothing left in use: only a // Lite session can trip them: a printed frame it previewed, or a GPS stamp // that arrived with a restored session. const proLook = pro ? null : proLookInUse({ frame: selectedFrame, gpsWm: useGeotag, hdf: adjustments.hdf ?? 0 }); // The native Kotlin exporter has no text-watermark draw, so a Lite export // (which always carries the mark) must take the Skia engine. const nativeExport = NATIVE_EXPORT && pro; // Shutter/export with a PRO look: nothing is rendered — name the feature so // the user knows which PRO build would render it. const promptPro = (feature: string) => { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); Alert.alert( 'PRO LOOK', `${feature} can be previewed and edited, but exporting it needs PRO.`, [ { text: 'NOT NOW', style: 'cancel' }, { text: 'UNLOCK PRO', onPress: () => setShowPro(true) }, ] ); }; // Saving, starring, spawning: the action IS the feature, so there is no look // to preview first — a LITE tap names it and nothing else happens. const promptProFeature = (feature: string) => { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); Alert.alert('PRO FEATURE', `${feature} needs PRO.`, [ { text: 'NOT NOW', style: 'cancel' }, { text: 'UNLOCK PRO', onPress: () => setShowPro(true) }, ]); }; // Watermark handed to the export engines (Skia + native): the ACCEPTED mark // (not the live draft), null while disabled or empty, else trimmed text + // position. Trimming here keeps the stamped text identical to what the // preview drew. const wmForExport = (): { text: string; x: number; y: number; color?: string; size?: number; font?: string | null; } | null => // The user's own mark is free in both tiers. LITE only narrows the FACE it // may use (one basic font), not the mark itself. customWmApplied.enabled && customWmApplied.text.trim().length > 0 ? { text: customWmApplied.text.trim(), x: customWmApplied.x, y: customWmApplied.y, color: customWmApplied.color, size: customWmApplied.size, font: pro ? customWmApplied.font : null, } : null; // The free build's own mark, burned in on top of the custom one. const liteMark = pro ? null : LITE_MARK; // `burst` marks one frame of a held-shutter burst: the loop already gave the // single haptic and the click would machine-gun, so both are muted, and RAW // is skipped (a DNG per frame doubles the exposures the burst has to fit). const handleCapture = async (opts?: { burst?: boolean }) => { if (isProcessing) return; // A PRO look can be composed but not rendered: stop before the shutter, so // no capture, no queue entry and no cache file is produced. if (proLook) { promptPro(proLook); 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 (!cameraReadyRef.current) { pendingShotRef.current = true; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); return; } pendingShotRef.current = false; if (!photoOutput) { Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.'); return; } // One capture in flight at a time — the shutter button, a viewfinder tap // and a burst frame all land here. if (captureBusyRef.current) return; captureBusyRef.current = true; if (!opts?.burst) 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; // Kick the fix off here but do NOT await it before the shutter: this is // a real GPS read plus a network reverse-geocode, and the sensor sat // idle for as long as it took. Resolved below, before the export queue. // Only the GPS MARK needs a fresh fix on the capture path: without it // the shutter must not wait for the receiver (two seconds of a // stamp-less photo is worse than none). EXIF still gets whatever fix // the launch prewarm already resolved. const gpsPending = gpsEnabled && useGeotag && !captureGps ? getCurrentGPS() : null; const capturedAt = Date.now(); // EXIF DateTimeOriginal / file name // 0.5x is a native Camera2 session on the hidden ultra-wide lens, not a // CameraX zoom ratio: no CameraX session is bound while it is live, so // the still has to come from the native module — and Camera2 fires no // shutter sound of its own. The LED is a CameraX capture setting, so the // native module re-applies the viewfinder's flashMode on its own still // request, and drops it back to 'off' on a lens with no flash unit. const ultraWideShot = viewfinderRef.current?.isUltraWide?.() === true; let filePath: string; if (ultraWideShot) { filePath = await UltraWide.captureAsync(flashMode); } else { filePath = ( await photoOutput.capturePhotoToFile( { enableShutterSound: shutterSound === 'on' && !opts?.burst, // Capture-time LED flash. Forced back to 'off' on a lens with no // flash unit: the spec throws for anything else. flashMode: flashAvailable ? flashMode : 'off', }, // The Nitro spec takes the callback bag as a required second // argument (every field in it optional) — one argument throws. {}, ) ).filePath; } const sourceUri = toUri(filePath); // RAW DNG sidecar: fire the second exposure immediately after the JPEG // so the two shots stay a few tens of ms apart, then file it into the // "RAW" album without blocking the JPEG export below. if (rawEnabled && rawPhotoOutput && !opts?.burst && !ultraWideShot) { try { const rawFile = await rawPhotoOutput.capturePhotoToFile( { enableShutterSound: shutterSound === 'on', // Same light as the JPEG it follows: the flash unit may still be // recharging tens of ms later, in which case the DNG just lands // darker than its JPEG — a silent raw, never a broken capture. flashMode: flashAvailable ? flashMode : 'off', }, {}, ); const rawUri = toUri(rawFile.filePath); saveRawToAlbum(rawUri).then((ok) => { if (!ok) { Alert.alert('RAW Not Saved', 'The DNG was captured but could not be added to the RAW album.'); } }); } catch (rawError) { console.error('RAW capture failed:', rawError); } } // Snapshot the look at shutter time, then render OFF the shutter path: // processAndExportPhoto (~1s Skia render + gallery save) is enqueued on // the FIFO background queue — the shutter returns in tens of ms and the // next shot can be taken immediately; no full-screen RENDERING overlay. // Finished photos land in history (and the gallery) when their turn // completes; the thumbnail button shows each as it arrives. // P5: the native path (NATIVE_EXPORT) renders through RecipesCamExport — // same queue/pushPhoto/Haptics/Alert behavior, different renderer. if (gpsPending) { const deviceGps = await withDeadline(gpsPending, GPS_WAIT_MS); if (deviceGps) { captureGps = { ...deviceGps, source: 'device' as const }; setGpsInfo(captureGps); } } const recipeName = activeRecipe.name; // Read at shutter time, not render time — the queued render runs after // the next shot and the gesture may have moved on. const frameZoom = viewfinderRef.current?.getFrameWindowZoom?.() ?? null; const render = async () => { const recipeArgs = { ...activeRecipe, adjustments: renderAdj, frameId: selectedFrame, useGeotag, }; const options = { // Live capture: gallery name RC_.jpg and the // shutter instant as the EXIF capture date. exported: false, capturedAt, // 300dpi header + full-res screen sharpen. No evFromCamera: the camera // AE bias is never written, so the export applies the same software EV // gain the preview shows. // aspect: crop to the on-screen composition ratio (full = keep 4:3 native). dpi: 300, sharpen: true, aspect: aspectRatio === 'full' ? undefined : aspectRatio, watermark: wmForExport(), liteMark, watermarkRotation: wmRotation, gpsWatermark: gpsWm, wallframeLandscape: wallLandscape, // FRAME-tab pinch/drag inside a polaroid card window or wall-frame // opening: save the same crop the preview showed (null = 1x). frameWindowZoom: frameZoom, }; const paint = await loadRenderer(nativeExport); return paint(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options); }; enqueueExport(async () => { try { const result = await render(); if (result) { // No auto preview after a capture — the thumbnail button opens // the swipe viewer. Saved to session history either way. pushPhoto({ uri: result.uri, recipeName, savedToLibrary: result.savedToLibrary, assetId: result.assetId, }); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } else { Alert.alert('Failed', 'Error occurred while processing the captured photo.'); } } catch (error) { console.error('Camera export failed:', error); 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 { captureBusyRef.current = 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); // Plain library frames export the photo at its own aspect (the ratio band is // a camera-composition tool); polaroid/wall keep their framed ratio crop. const libraryPlainFrame = selectedFrame === 'none' || selectedFrame === 'classic-white' || selectedFrame === 'cinematic'; const libraryAspect = aspectRatio === 'full' || libraryPlainFrame ? undefined : aspectRatio; // FRAME-tab CROP: only the plain frames (polaroid/wall crop through their // own window). The native exporter knows nothing about it, so a cropped // export always takes the Skia path. // The fixed ratios take their rectangle from the viewfinder: the band plus // whatever pinch/drag the user applied inside it. cropWH stays as the // fallback for the (impossible in practice) case of an unloaded photo. const ratioCrop = cropRatio !== 'none' && cropRatio !== 'free' ? viewfinderRef.current?.getCropRatioRect?.() ?? null : null; const cropOptions: { cropWH?: number; cropRect?: CropRect } = !libraryPlainFrame || cropRatio === 'none' ? {} : cropRatio === 'free' ? { cropRect } : ratioCrop ? { cropRect: ratioCrop } : { cropWH: CROP_W_H[cropRatio] }; const paint = await loadRenderer(nativeExport && !cropOptions.cropRect); const result = nativeExport && !cropOptions.cropRect ? await paint( libraryImageUri, { ...selectedRecipe, adjustments, frameId: selectedFrame, useGeotag, }, selectedFrame, useGeotag, gpsInfo, // Library export keeps the on-screen composition: plain frames export // the photo as seen (own aspect), polaroid/wall keep their ratio crop. { aspect: libraryAspect, // Library export: RC__exported.jpg, and the loaded photo's // EXIF (camera, dates, GPS) is carried into the file. exported: true, watermark: wmForExport(), liteMark, watermarkRotation: wmRotation, gpsWatermark: gpsWm, wallframeLandscape: wallLandscape, } ) : await paint( libraryImageUri, { ...selectedRecipe, adjustments, frameId: selectedFrame, useGeotag, }, selectedFrame, useGeotag, gpsInfo, // Library export keeps the on-screen composition: plain frames export // the photo as seen (own aspect), polaroid/wall keep their ratio crop. { aspect: libraryAspect, // Library export: RC__exported.jpg, and the loaded photo's // EXIF (camera, dates, GPS) is carried into the file. exported: true, watermark: wmForExport(), liteMark, watermarkRotation: wmRotation, gpsWatermark: gpsWm, wallframeLandscape: wallLandscape, // FRAME-tab CROP: the exact keep-rectangle the preview showed. ...cropOptions, // Pinch/drag inside a polaroid card window or wall-frame opening: // save the same crop the preview showed (null = centered 1x). frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null, photoRotation, photoStraighten, } ); setIsProcessing(false); if (result) { pushPhoto({ uri: result.uri, recipeName: activeRecipe.name, savedToLibrary: result.savedToLibrary, assetId: result.assetId, }); // Library has no thumbnail button (right button switches back to camera), // so the export result auto-opens the viewer on the newest photo. setViewerIndex(photoHistory.length); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } else { Alert.alert('Failed', 'Error occurred while processing image.'); } }; // Volume keys as a shutter release. `handleCapture` is rebuilt every render, // so the listener is bound once and reaches the current one through a ref. const captureFromKeyRef = useRef(handleCapture); captureFromKeyRef.current = handleCapture; // Session up -> replay a queued press. Stable identity: the Viewfinder's // onStarted/onStopped callbacks are memoised around it. const handleCameraReady = useCallback((ready: boolean) => { cameraReadyRef.current = ready; if (ready && pendingShotRef.current) { pendingShotRef.current = false; setTimeout(() => void captureFromKeyRef.current(), 0); } }, []); useEffect(() => { const sub = VolumeShutter.addListener('onShutter', () => { void captureFromKeyRef.current(); }); return () => sub.remove(); }, []); // Only the camera screen claims the keys; everywhere else (the library) // they stay volume keys. The modals need no flag of their own: an RN // Modal is a window of its own and holds the focus, so the activity never // sees a key while one is up. useEffect(() => { VolumeShutter.setEnabled(mode === 'camera'); }, [mode]); // Held-shutter burst: press and hold the shutter, frames keep coming until // the finger lifts. Every frame is a normal capture (GPS resolve, RAW rules, // background render enqueue), so a burst photo is indistinguishable from a // tapped one apart from the muted haptic/sound. The `await` IS the pacing: a // frame cannot start before the previous one is on disk, which is why no // timer is needed (and why 'speed' buys nothing extra here). // ponytail: BURST_MAX is a runaway guard for a lost press-out (responder // terminated mid-hold); raise it if bursts longer than 30 frames are wanted. const BURST_MAX = 30; const burstRef = useRef(false); const handleBurstStart = () => { if (mode !== 'camera' || burstRef.current) return; if (proLook) { promptPro(proLook); return; } burstRef.current = true; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); void (async () => { for (let i = 0; i < BURST_MAX && burstRef.current; i++) { await handleCapture({ burst: true }); } burstRef.current = false; })(); }; const handleBurstStop = () => { burstRef.current = false; }; // 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 === mode) return; if (next === 'camera' && gpsInfo && gpsInfo.source !== 'device') { setGpsInfo(null); } // The deck panels belong to one mode only (FRAME exists in library alone, // and the WATERMARK panel is the watermark's edit session), so every switch // closes them: no frame chip row over the live preview, and no watermark // placement layer left eating the camera's tap-to-focus. setOpenTab(null); setOpenParamKey(null); resetEditsToRecipe(); // Camera mode has no FRAME picker to undo a frame, so even a recipe that // carries one must not follow the user onto the live camera (and into the // capture, which burns the frame in). if (next === 'camera') setSelectedFrame('none'); setMode(next); }; // Append a finished export (camera capture or library export) to the session // photo history. Returned entry id lets callers delete by id. const pushPhoto = (p: Omit): string => { const id = `photo-${Date.now()}-${photoSeq.current++}`; setPhotoHistory((h) => [...h, { id, ...p }]); return id; }; // Thumbnail (camera mode) opens the viewer on the newest photo. const openLatestViewer = () => { if (photoHistory.length > 0) setViewerIndex(photoHistory.length - 1); }; // Viewer trash: delete the photo from the app AND its gallery copy (when it // was saved). Deleting a saved photo goes through Android's per-item system // confirmation (scoped storage — every single delete asks again by design); // if the user cancels THAT dialog the photo is kept in both places. const handleDeletePhoto = async (id: string) => { const idx = photoHistory.findIndex((p) => p.id === id); if (idx < 0) return; const entry = photoHistory[idx]; const remove = () => { const next = photoHistory.filter((p) => p.id !== id); setPhotoHistory(next); setViewerIndex((prev) => { if (prev === null) return null; if (next.length === 0) return null; if (prev > idx) return prev - 1; if (prev >= next.length) return next.length - 1; return prev; }); }; const deleteCache = async () => { try { await FileSystem.deleteAsync(entry.uri, { idempotent: true }); } catch (e) { console.warn('Photo cache delete failed:', e); } }; if (!entry.savedToLibrary || !entry.assetId) { // Never reached the gallery — nothing for the OS to confirm. remove(); deleteCache(); return; } try { // deleteAssetsAsync shows the OS delete confirmation when the photo is // in the gallery. false = user cancelled it → keep everything. const ok = await MediaLibraryLegacy.deleteAssetsAsync([entry.assetId]); if (!ok) { Alert.alert('Photo Kept', 'Android did not confirm the deletion, so the photo stays here and in your gallery.'); return; } remove(); deleteCache(); } catch (e) { console.warn('Gallery delete failed:', e); Alert.alert('Delete Failed', 'Could not remove the photo from your gallery. It stays here too.'); } }; // Viewer SAVE & DONE: photo was exported to cache only — copy it into the // gallery now and remember its assetId so a later trash can remove it. const handleSavePhoto = async (id: string) => { const entry = photoHistory.find((p) => p.id === id); if (!entry || entry.savedToLibrary) return; const perm = await MediaLibraryLegacy.requestPermissionsAsync(); if (!perm.granted) { Alert.alert('Not Saved', 'Media library access was denied, so this photo was not saved to your gallery.'); return; } try { const asset = await MediaLibraryLegacy.createAssetAsync(entry.uri); setPhotoHistory((h) => h.map((p) => (p.id === id ? { ...p, savedToLibrary: true, assetId: asset.id } : p))); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } catch (e) { console.error('Save to gallery failed:', e); Alert.alert('Save Failed', 'Could not save this photo to your gallery.'); } }; // Tool-rail toggle: closing the panel (or switching tabs) also drops the open // parameter row — otherwise a stale slider row (and its image-drag target) // would survive into the next panel. const handleToggleTab = (tab: TabId) => { setOpenTab((prev) => (prev === tab ? null : tab)); setOpenParamKey(null); }; // While a panel parameter row is open, a vertical drag anywhere on the live // camera image adjusts that same parameter (the open slider follows). Open // row wins over the locked-AE EV drag; a plain tap still locks focus. const imageAdjustTarget = (() => { if (mode !== 'camera' || openTab !== 'iq' && openTab !== 'wb' && openTab !== 'filters') return null; if (!openParamKey) return null; const list = openTab === 'iq' ? PARAM_DEFS.iq : openTab === 'wb' ? PARAM_DEFS.wb : PARAM_DEFS.filters; const def = list.find((d) => d.key === openParamKey); if (!def) return null; return { key: def.key, min: def.min, max: def.max, step: def.step, value: def.get(adjustments), onChange: (v: number) => handleUpdateAdjustments(def.set(v)), }; })(); return ( {/* Fullscreen hero: photo or live preview fills the whole screen */} commit(true, () => setCropRect(r))} onSessionError={handleCameraSessionError} onCameraReady={handleCameraReady} onExposureChange={(ev) => handleUpdateAdjustments({ exposureCompensation: ev })} meteringAdjustments={meteringActive ? meteringOverlay(shownAdjustments) : undefined} imageAdjustTarget={imageAdjustTarget} onComparePeek={peekArmed ? handlePeek : undefined} // Touch-to-shutter: tap the live image -> focus locks -> capture. onTouchShutter={touchToShutter ? handleCapture : undefined} customWm={shown.customWm} wmRotation={shown.wmRotation} gpsWm={shown.gpsWm} onGpsWmChange={(x, y, size) => commit(true, () => setGpsWm((g) => ({ ...g, x, y, size })))} onWmSizeChange={(size) => commit(true, () => setCustomWm((p) => ({ ...p, size })))} wallLandscape={shown.wallLandscape} // The mark only captures image touches while its panel is open, so a // watermark left ON cannot block tap-to-focus or pinch/zoom. wmEditing={openTab === 'frame' && wmPanelOpen} frameTabActive={openTab === 'frame'} onWmPositionChange={(x, y) => commit(true, () => setCustomWm((p) => ({ ...p, x, y })))} onWmEdit={() => { // Double-tap on the mark re-opens its editor (and with it the // placement layer), then focuses the input. setOpenTab('frame'); setOpenParamKey(null); setWmEditNonce((n) => n + 1); }} /> {/* Top overlay: mode label + recipe name (left), Save current (right) */} setEditingRecipe(selectedRecipe) : undefined } onExportShare={handleExportShare} onUndo={handleUndo} undoDepth={undoDepth} // Capture-time LED flash: offered only on a lens that carries a flash // unit, and only in camera mode (the library save never fires the // camera). Tap cycles OFF -> AUTO -> ON -> OFF. flashMode={mode === 'camera' && flashAvailable ? flashMode : undefined} onCycleFlash={ mode === 'camera' && flashAvailable ? () => setFlashMode((m) => (m === 'off' ? 'auto' : m === 'auto' ? 'on' : 'off')) : undefined } onHeight={setTopInset} /> {/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */} setDeckTop(e.nativeEvent.layout.y)} > {openTab !== null && ( commit(false, () => setCropRatio(r))} cropApplied={shown.cropApplied} onApplyCrop={() => commit(false, () => setCropApplied(cropRatio))} cropEnabled={ mode === 'library' && (selectedFrame === 'none' || selectedFrame === 'classic-white' || selectedFrame === 'cinematic') } photoRotation={shown.photoRotation} photoStraighten={shown.photoStraighten} onPickRotation={(d) => commit(false, () => setPhotoRotation(d))} onStraighten={(d) => commit(true, () => setPhotoStraighten(d))} onAutoStraighten={handleAutoStraighten} wallLandscape={shown.wallLandscape} onToggleWallLandscape={() => commit(false, () => setWallLandscape((v) => !v))} useGeotag={shown.useGeotag} pro={pro} openParam={openParamKey} onOpenParamChange={setOpenParamKey} currentBaseFilter={selectedRecipe?.baseFilter} onCreateRecipe={handleCreateRecipe} editingRecipe={editingRecipe} onCloseEdit={() => setEditingRecipe(null)} onUpdateRecipe={handleUpdateRecipe} onSelectRecipe={handleSelectRecipe} favoriteIds={favoriteIds} onToggleFavorite={handleToggleFavorite} onUpdateAdjustments={handleUpdateAdjustments} onUpdateFrame={handleUpdateFrame} onToggleGeotag={handleToggleGeotag} onSaveRecipe={handleSaveRecipe} onImportRecipe={handleImportRecipe} recipeLimitReached={liteSaveBlocked} onRecipeLimit={promptRecipeLimit} onDeleteRecipe={handleDeleteRecipe} customWm={shown.customWm} onUpdateCustomWm={handleUpdateCustomWm} gpsWm={shown.gpsWm} onUpdateGpsWm={(wm) => commit(true, () => setGpsWm(wm))} onAcceptCustomWm={handleAcceptCustomWm} onCancelCustomWm={handleCancelCustomWm} wmEditNonce={wmEditNonce} wmRotation={shown.wmRotation} onRotateWm={() => commit(false, () => setWmRotation((r) => (r + 90) % 360))} onWmPanelChange={setWmPanelOpen} /> )} {/* Library picker (grid -> zoomable preview -> USE / CANCEL) */} setLibraryPickerVisible(false)} onSelect={handleLibraryPicked} /> setSettingsVisible(true)} onFlipCamera={() => setCameraFacing((f) => (f === 'back' ? 'front' : 'back'))} isLibraryMode={mode === 'library'} onSwitchToCamera={() => handleSetMode('camera')} /> {/* Gear sheet: shutter sound / ratio+RAW / metering mode */} setSettingsVisible(false)} aspectRatio={aspectRatio} onAspectRatio={handleAspectRatio} rawEnabled={rawEnabled} onRawEnabled={handleRawEnabled} cameraMode={mode === 'camera'} meterMode={meterMode} onMeterModeChange={setMeterMode} shutterSound={shutterSound} onShutterSound={setShutterSound} touchToShutter={touchToShutter} onTouchToShutter={setTouchToShutter} gpsEnabled={gpsEnabled} onGpsEnabled={handleToggleGps} startupMode={startupMode} onStartupMode={handleStartupMode} pro={pro} onUpgrade={() => setShowPro(true)} /> {/* The paywall: the only place the tier is sold. */} setShowPro(false)} /> {/* Session photo viewer (swipe across captured/exported photos) */} 0} photos={photoHistory} initialIndex={viewerIndex ?? 0} onClose={() => setViewerIndex(null)} onDelete={handleDeletePhoto} onSave={handleSavePhoto} /> {/* Global Processing Loader Overlay */} {isProcessing && ( RENDERING PHOTO... )} ); }