Files
RecipesCam/App.tsx
T
admin a662674478 Show the pre-edit values while the finger holds, and pass the FRAME tab flag
Pressing and holding on the picture with the LIGHT/WB/FX panel open now
rewinds the last edit, and the panel rewinds with it: the chip numbers and
the open slider read the same pre-edit snapshot, so before and after can be
compared by eye and not just by looking at the picture. Only the displayed
values move -- the stored adjustments, RECIPE and the export keep the real
ones, and a gap longer than PEEK_GAP_MS starts a new snapshot for the next
gesture.

Also pass frameTabActive, without which the FRAME preview zoom added in
Viewfinder.tsx could never turn on.
2026-09-12 08:51:43 +07:00

1525 lines
66 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react';
import { View, StatusBar, Alert, ActivityIndicator, Text, AppState, Platform, Keyboard } 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 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 { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, 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 } from './src/utils/locationUtils';
import { resolvePhotoGPS } from './src/utils/photoGps';
import { processAndExportPhoto } from './src/utils/exportEngine';
import { processAndExportPhotoNative } from './src/utils/nativeExport';
import { PARAM_DEFS } from './src/utils/paramDefs';
// @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 = <T extends object>(a: T, b: T) => {
const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set<keyof T>;
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';
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: flushes the EV to
// the hardware AE bias right before each capture (live slider drags never
// touch the hardware — that flashed the Xiaomi preview).
const viewfinderRef = useRef<ViewfinderHandle>(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<number | null>(null);
// 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<Recipe[]>([]);
// 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<string[]>([]);
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null);
// Custom recipe open in the parameter editor (the CREATE form in edit mode).
const [editingRecipe, setEditingRecipe] = useState<Recipe | null>(null);
// Custom temporary overrides for the sliders
const [adjustments, setAdjustments] = useState<ColorAdjustments>({ ...DEFAULT_ADJUSTMENTS });
const [selectedFrame, setSelectedFrame] = useState<FrameId>('none');
const [useGeotag, setUseGeotag] = useState(true);
// 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<GPSInfo | null>(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<CustomWatermark>({ ...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<GpsWatermark>({ ...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<CustomWatermark>({
...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) => {
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 = () => {
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 = () => {
setCustomWm({ ...customWmApplied });
closeWatermarkEditor();
};
// Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar.
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('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<CropRatio>('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<CropRatio | null>(null);
const [cropRect, setCropRect] = useState<CropRect>(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<MeterMode>('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';
const meteringAdj: Partial<ColorAdjustments> | undefined = meteringActive
? {
exposureCompensation: clampAdj((adjustments.exposureCompensation ?? 0) + METER_HL_EV),
highlight: clampAdj((adjustments.highlight ?? 0) + METER_HL_HIGHLIGHT),
shadow: clampAdj((adjustments.shadow ?? 0) + METER_HL_SHADOW),
contrast: clampAdj((adjustments.contrast ?? 0) + METER_HL_CONTRAST),
clarity: clampAdj((adjustments.clarity ?? 0) + METER_HL_CLARITY),
}
: 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<TabId | null>(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<string | null>(null);
// FRAME → WATERMARK sub-panel is open (the rail's WATERMARK tab is gone).
const [wmPanelOpen, setWmPanelOpen] = useState(false);
const [libraryImageUri, setLibraryImageUri] = useState<string | null>(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<PhotoEntry[]>([]);
// Index into photoHistory of the photo shown in the swipe viewer; null = closed.
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
// 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<ShutterSound>('on');
// Startup behavior: 'default' = PROVIPES neutral at 4:3 camera; 'last' = restore
// previous session from AsyncStorage. Default is 'default'.
const [startupMode, setStartupMode] = useState<StartupMode>('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);
// 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<void>>(Promise.resolve());
const enqueueExport = (task: () => Promise<void>) => {
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();
// Full-resolution photo output — attached to the live <Camera> in Viewfinder.
// Photo capture is resolution-independent from the Skia preview output.
const photoOutput = usePhotoOutput({
qualityPrioritization: 'quality',
});
// 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}`);
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
}, []);
// P0 spike: native decode/encode benchmark (EXPO_PUBLIC_BENCH=1 builds only).
useEffect(() => {
if (process.env.EXPO_PUBLIC_BENCH === '1') {
import('./src/dev/nativeBenchProbe')
.then((m) => m.nativeBenchProbe())
.catch(() => {});
}
}, []);
// P6 parity probe: same source through legacy Skia and native engines
// (EXPO_PUBLIC_PARITY=1 builds only).
useEffect(() => {
if (process.env.EXPO_PUBLIC_PARITY === '1') {
import('./src/dev/parityProbe')
.then((m) => m.parityProbe())
.catch((e) => console.error('[PARITY] effect failed', e));
}
}, []);
// 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]);
// 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.meterMode === 'evaluative' || s.meterMode === 'highlight') setMeterMode(s.meterMode);
if (typeof s.rawEnabled === 'boolean') setRawEnabled(s.rawEnabled);
if (typeof s.gpsEnabled === 'boolean') setGpsEnabled(s.gpsEnabled);
}
} 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;
}
}
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.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<CustomWatermark>;
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: 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<GpsWatermark>;
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) => {
setSelectedRecipe(recipe);
setAdjustments(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 film sim swaps the look only: everything the user dialled in on
// the LIGHT/WB/FX tabs and the frame they picked stay exactly as they are.
// A saved recipe still loads its own stored values (that is what saving it
// was for). Same trick as the session restore below: set the recipe without
// applyRecipe, which would snap the sliders back to the sim's canonical set.
const handleSelectRecipe = (recipe: Recipe) => {
if (!FILM_SIMS.some((s) => s.id === recipe.id)) {
applyRecipe(recipe);
return;
}
setSelectedRecipe({ ...recipe, adjustments, frameId: selectedFrame });
};
// 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 });
};
// 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.
const handleResetAll = () => {
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);
// ---- Hold-to-compare -----------------------------------------------------
// While a LIGHT/WB/FX panel is open, pressing and holding on the picture
// shows it with the last edit undone, so the user can flick between before
// and after. The picture and the parameter display both follow the peek;
// the stored values, RECIPE and the export never move.
const PEEK_GAP_MS = 700; // a longer pause = a new gesture, so re-snapshot
const peekRef = useRef<{ before: ColorAdjustments | null; at: number }>({
before: null,
at: 0,
});
const [peekAdjustments, setPeekAdjustments] = useState<ColorAdjustments | null>(null);
const peekArmed = openTab === 'iq' || openTab === 'wb' || openTab === 'filters';
const handleComparePeek = useCallback((on: boolean) => {
setPeekAdjustments(on ? peekRef.current.before : null);
}, []);
// The values the panel and the picture SHOW. While the finger is down they
// fall back to the pre-edit snapshot, so the chip numbers and the open slider
// travel back with the picture and the before/after is readable, not just
// visible. The real values stay untouched: writes, RECIPE and the on-image
// drag baseline all keep reading `adjustments`.
const shownAdjustments = peekArmed && peekAdjustments ? peekAdjustments : adjustments;
const withPeek = (r: Recipe): Recipe =>
shownAdjustments === r.adjustments ? r : { ...r, adjustments: shownAdjustments };
const handleUpdateAdjustments = (updates: Partial<ColorAdjustments>) => {
// 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;
// Remember where THIS edit started. A pause longer than PEEK_GAP_MS counts
// as a new gesture, so the snapshot is retaken; within one drag the earlier
// value survives and the peek shows the drag's own starting point.
// ponytail: a >700ms stall in the middle of one drag re-snapshots mid-way.
const peekNow = Date.now();
if (peekNow - peekRef.current.at > PEEK_GAP_MS) {
peekRef.current.before = adjustments;
}
peekRef.current.at = peekNow;
// 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);
// 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) => {
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, meterMode, rawEnabled, gpsEnabled })
).catch(() => {});
}, [shutterSound, meterMode, rawEnabled, gpsEnabled]);
// 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<boolean> => {
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 (!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" 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<Recipe, 'id' | 'isCustom'>) => {
const target = editingRecipe;
setEditingRecipe(null);
if (!target) return;
const name = recipe.name.trim();
const unchanged = name === target.name.trim();
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<Recipe, 'id' | 'isCustom'>) => {
const newRecipe = await saveCustomRecipe(recipe);
const list = await getAllRecipes();
setRecipes(list);
applyRecipe(newRecipe);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
};
const handleDeleteRecipe = async (id: string) => {
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);
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;
setPhotoStraighten(-tilt);
}, []);
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), which silently kills the photo's own
// coordinates. Request media read + photo-location BEFORE the pick so the
// copy keeps its true EXIF GPS.
if (gpsEnabled && Platform.OS === 'android') {
try {
await MediaLibrary.requestPermissionsAsync(false, ['photo']);
} catch (e) {
console.warn('Pre-pick media+photo-location permission error:', e);
}
}
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. 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. Always on: the
// photo keeps its own EXIF GPS even when the chip is off at pick time, so
// switching the chip on later still stamps the photo's real place.
legacy: Platform.OS === 'android',
});
if (!result.canceled && result.assets && result.assets.length > 0) {
const asset = result.assets[0];
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 { 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);
}
};
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;
// 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 =>
customWmApplied.enabled && customWmApplied.text.trim().length > 0
? {
text: customWmApplied.text.trim(),
x: customWmApplied.x,
y: customWmApplied.y,
color: customWmApplied.color,
size: customWmApplied.size,
font: customWmApplied.font,
}
: null;
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;
}
captureBusyRef.current = 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 (gpsEnabled && !captureGps) {
const deviceGps = await getCurrentGPS();
if (deviceGps) {
captureGps = { ...deviceGps, source: 'device' as const };
setGpsInfo(captureGps);
}
}
const capturedAt = Date.now(); // EXIF DateTimeOriginal / file name
const { filePath } = await photoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {});
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) {
try {
const rawFile = await rawPhotoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {});
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.
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 = () => {
const recipeArgs = {
...activeRecipe,
adjustments: renderAdj,
frameId: selectedFrame,
useGeotag,
};
const options = {
// Live capture: gallery name RC_<yyyymmdd_hhmmss>.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(),
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,
};
return NATIVE_EXPORT
? processAndExportPhotoNative(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options)
: processAndExportPhoto(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 result = NATIVE_EXPORT && !cropOptions.cropRect
? await processAndExportPhotoNative(
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_<ts>_exported.jpg, and the loaded photo's
// EXIF (camera, dates, GPS) is carried into the file.
exported: true,
watermark: wmForExport(),
watermarkRotation: wmRotation,
gpsWatermark: gpsWm,
wallframeLandscape: wallLandscape,
}
)
: await processAndExportPhoto(
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_<ts>_exported.jpg, and the loaded photo's
// EXIF (camera, dates, GPS) is carried into the file.
exported: true,
watermark: wmForExport(),
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.');
}
};
// 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<PhotoEntry, 'id'>): 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 (
<View className="flex-1 bg-black">
<StatusBar barStyle="light-content" translucent backgroundColor="transparent" />
{/* Fullscreen hero: photo or live preview fills the whole screen */}
<View className="absolute inset-0 bg-black">
<Viewfinder
ref={viewfinderRef}
mode={mode}
facing={cameraFacing}
recipe={withPeek(
selectedRecipe || {
id: 'temp',
name: 'Custom',
baseFilter: 'none',
adjustments,
frameId: selectedFrame,
useGeotag,
}
)}
selectedFrame={selectedFrame}
useGeotag={useGeotag}
gpsInfo={gpsInfo}
libraryImageUri={libraryImageUri}
availableHeight={deckTop}
photoRotation={photoRotation}
photoStraighten={photoStraighten}
cameraPermissionGranted={cameraPermissionGranted}
onRequestCameraPermission={requestCameraPermission}
photoOutput={photoOutput}
rawOutput={rawPhotoOutput}
rawEnabled={rawEnabled}
aspectRatio={aspectRatio}
cropRatio={cropRatio}
cropApplied={cropApplied}
cropRect={cropRect}
onCropRectChange={setCropRect}
onSessionError={handleCameraSessionError}
onExposureChange={(ev) => handleUpdateAdjustments({ exposureCompensation: ev })}
meteringAdjustments={meteringAdj}
imageAdjustTarget={imageAdjustTarget}
onComparePeek={peekArmed ? handleComparePeek : undefined}
customWm={customWm}
wmRotation={wmRotation}
gpsWm={gpsWm}
onGpsWmChange={(x, y, size) => setGpsWm((g) => ({ ...g, x, y, size }))}
onWmSizeChange={(size) => setCustomWm((p) => ({ ...p, size }))}
wallLandscape={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) => 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);
}}
/>
</View>
{/* Top overlay: mode label + recipe name (left), Save current (right) */}
<TopBar
mode={mode}
recipeName={activeRecipe.name}
onSaveRecipe={handleSaveRecipe}
onOverwriteRecipe={selectedRecipe?.isCustom ? handleOverwriteRecipe : undefined}
onEditRecipe={
selectedRecipe?.isCustom ? () => setEditingRecipe(selectedRecipe) : undefined
}
/>
{/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */}
<View
className="absolute left-0 right-0 bottom-0 z-10"
style={{ paddingBottom: keyboardHeight }}
onLayout={(e) => setDeckTop(e.nativeEvent.layout.y)}
>
{openTab !== null && (
<View
style={{
backgroundColor: 'rgba(8,8,10,0.96)',
borderTopWidth: 1,
borderTopColor: 'rgba(63,63,70,0.7)',
}}
className="rounded-t-2xl overflow-hidden border-l border-r border-zinc-800/60"
>
<AdjustmentPanel
activeTab={openTab}
recipes={recipes}
currentRecipeId={selectedRecipe?.id || ''}
adjustments={shownAdjustments}
onResetAll={handleResetAll}
resetDirty={resetDirty}
selectedFrame={selectedFrame}
cropRatio={cropRatio}
onUpdateCropRatio={setCropRatio}
cropApplied={cropApplied}
onApplyCrop={() => setCropApplied(cropRatio)}
cropEnabled={
mode === 'library' &&
(selectedFrame === 'none' ||
selectedFrame === 'classic-white' ||
selectedFrame === 'cinematic')
}
photoRotation={photoRotation}
photoStraighten={photoStraighten}
onPickRotation={setPhotoRotation}
onStraighten={setPhotoStraighten}
onAutoStraighten={handleAutoStraighten}
wallLandscape={wallLandscape}
onToggleWallLandscape={() => setWallLandscape((v) => !v)}
useGeotag={useGeotag}
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}
onDeleteRecipe={handleDeleteRecipe}
customWm={customWm}
onUpdateCustomWm={handleUpdateCustomWm}
gpsWm={gpsWm}
onUpdateGpsWm={setGpsWm}
onAcceptCustomWm={handleAcceptCustomWm}
onCancelCustomWm={handleCancelCustomWm}
wmEditNonce={wmEditNonce}
wmRotation={wmRotation}
onRotateWm={() => setWmRotation((r) => (r + 90) % 360)}
onWmPanelChange={setWmPanelOpen}
/>
</View>
)}
<ToolRail activeTab={openTab} onToggle={handleToggleTab} />
<CameraControls
lastPhotoUri={lastPhotoUri}
onCapture={handleCapture}
onPickImage={handlePickImage}
onOpenPreview={openLatestViewer}
onOpenSettings={() => setSettingsVisible(true)}
onFlipCamera={() => setCameraFacing((f) => (f === 'back' ? 'front' : 'back'))}
isLibraryMode={mode === 'library'}
onSwitchToCamera={() => handleSetMode('camera')}
/>
</View>
{/* Gear sheet: shutter sound / ratio+RAW / metering mode */}
<SettingsModal
visible={settingsVisible}
onClose={() => setSettingsVisible(false)}
aspectRatio={aspectRatio}
onAspectRatio={handleAspectRatio}
rawEnabled={rawEnabled}
onRawEnabled={handleRawEnabled}
cameraMode={mode === 'camera'}
meterMode={meterMode}
onMeterModeChange={setMeterMode}
shutterSound={shutterSound}
onShutterSound={setShutterSound}
gpsEnabled={gpsEnabled}
onGpsEnabled={handleToggleGps}
startupMode={startupMode}
onStartupMode={handleStartupMode}
/>
{/* Session photo viewer (swipe across captured/exported photos) */}
<PhotoViewerModal
visible={viewerIndex !== null && photoHistory.length > 0}
photos={photoHistory}
initialIndex={viewerIndex ?? 0}
onClose={() => setViewerIndex(null)}
onDelete={handleDeletePhoto}
onSave={handleSavePhoto}
/>
{/* Global Processing Loader Overlay */}
{isProcessing && (
<View className="absolute inset-0 bg-black/75 flex items-center justify-center space-y-4">
<ActivityIndicator size="large" color="#f59e0b" />
<Text className="text-amber-500 font-mono text-sm tracking-widest font-bold">
RENDERING PHOTO...
</Text>
</View>
)}
</View>
);
}