Files
RecipesCam/App.tsx
T
admin 40c91d2a75 feat(gps): a Settings GPS switch that reads the location; the watermark chip only prints it
The watermark chip used to be the only GPS control, so turning the watermark
off also stopped the photo from carrying coordinates. Split the two: a GPS
master switch in Settings decides whether the location is read at all, and the
chip decides whether it is drawn on the photo.

0x889d is also written as UTF-8 now — a place name with accents ("TAM KỲ, ĐÀ
NẴNG") was going out latin-1 and reading back as mojibake.
2026-09-11 20:08:59 +07:00

1490 lines
64 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);
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;
// 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={
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}
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}
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={adjustments}
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>
);
}