97949e449d
Both flavors build from one JS tree: `lite` hands out the free apk (com.locphamtran.recipescamera), `pro` sells as-is (same id + .pro, label "RecipesCam Pro"). Release is signed from android/keystore.properties, both files gitignored - lose either and no later build can install over this one. The tier reaches JS through src/provariant.ts, which tools/set-variant.mjs rewrites before each build (`npm run apk:lite` / `apk:pro`); the gradle flavor alone would ship a Pro apk that thinks it is Lite. The dev scaffolding goes: the three src/dev probes and their App effects, the EXPO_PUBLIC_CAPTURE_MODE [CAPTURE] timing log, and the -PdevtestSuffix side-by-side install hook. 'balanced' stays the shipped capture mode, which is what those probes measured on the 12S Ultra. Looking at the picture: WB now converts kelvin through the Planckian locus into a luma-normalised gain (unity at 5500K, symmetric tint) instead of the old one-sided push; LEITZ STREETLIFE splits into CLASSIC and VIVID; selecting a recipe loads every knob it carries over the defaults and stays editable, and RESET hands the panel back to the sim's own values. The in-app library picker, the ultra-wide native module and recipe share/import land here too. CLARITY and LIGHT GLOW existed only on the camera worklet and the export engine, so a photo opened from the library ignored both. They now run in all three library preview branches (libPhoto), and the preview clips them to the drawn image - the export engine always clipped its bloom to the canvas, the preview drew it over the whole rect, so the bloom bled past the photo onto the black letterbox and over the frame's mat.
1740 lines
76 KiB
TypeScript
1740 lines
76 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 DocumentPicker from 'expo-document-picker';
|
|
import * as Sharing from 'expo-sharing';
|
|
import * as FileSystem from 'expo-file-system/legacy';
|
|
import * as MediaLibraryLegacy from 'expo-media-library/legacy';
|
|
import * as Haptics from 'expo-haptics';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
|
|
import TopBar from './src/components/TopBar';
|
|
import Viewfinder, { type ViewfinderHandle } from './src/components/Viewfinder';
|
|
import AdjustmentPanel from './src/components/AdjustmentPanel';
|
|
import CameraControls from './src/components/CameraControls';
|
|
import ToolRail, { TabId } from './src/components/ToolRail';
|
|
import SettingsModal from './src/components/SettingsModal';
|
|
import PhotoViewerModal from './src/components/PhotoViewerModal';
|
|
import LibraryPickerModal from './src/components/LibraryPickerModal';
|
|
|
|
import { 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, PickedPhotoAsset } from './src/utils/photoGps';
|
|
import { processAndExportPhoto } from './src/utils/exportEngine';
|
|
import { processAndExportPhotoNative } from './src/utils/nativeExport';
|
|
import { exportRecipeXml, importRecipeXml } from './src/utils/recipeShare';
|
|
import { PARAM_DEFS } from './src/utils/paramDefs';
|
|
import { LITE_MARK, LITE_RECIPE_LIMIT, activatePro, loadPro, proLookInUse } from './src/utils/entitlement';
|
|
import UltraWide from './modules/recipescam-ultrawide';
|
|
|
|
// @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';
|
|
|
|
// B1: the shutter's CameraX capture mode. 'balanced' ships as the default —
|
|
// measured on the 12S Ultra at the same 3000x4000, a frame takes ~340 ms
|
|
// against ~540 ms for 'quality', with no resolution loss, because this device
|
|
// has ZSL (supportsSpeedQualityPrioritization) and 'balanced'/speed ride it.
|
|
// EXPO_PUBLIC_CAPTURE_MODE still overrides, but ONLY in dev bundles: Metro
|
|
// inlines EXPO_PUBLIC_*, the release HBC build does not (B0 finding) — never
|
|
// gate shipped behaviour on it.
|
|
const CAPTURE_MODE =
|
|
process.env.EXPO_PUBLIC_CAPTURE_MODE === 'speed' || process.env.EXPO_PUBLIC_CAPTURE_MODE === 'quality'
|
|
? process.env.EXPO_PUBLIC_CAPTURE_MODE
|
|
: 'balanced';
|
|
|
|
export default function App() {
|
|
const [mode, setMode] = useState<'camera' | 'library'>('camera');
|
|
// Back/front lens the live viewfinder uses; flipping is transient UI state
|
|
// (not persisted in sessions — startup always opens on the back camera).
|
|
const [cameraFacing, setCameraFacing] = useState<'back' | 'front'>('back');
|
|
// Imperative handle into the Viewfinder camera controller: reads the live
|
|
// viewfinder state at capture/export time (framed-window zoom, crop rect,
|
|
// horizon). There is no EV flush to do: the hardware AE bias is never
|
|
// touched — setExposureBias re-locks AE and flashes the Xiaomi preview.
|
|
const viewfinderRef = useRef<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[]>([]);
|
|
// Lite (free) vs PRO (unlocked key). Read once at startup; the unlock key
|
|
// flips it in place, so every later export drops the mark and the look gate.
|
|
const [pro, setPro] = useState(false);
|
|
useEffect(() => {
|
|
loadPro().then(setPro);
|
|
}, []);
|
|
// Lite stores up to LITE_RECIPE_LIMIT recipes of its own; past that the save
|
|
// paths ask for PRO. Deleting one is PRO as well, so a Lite list only grows.
|
|
const customRecipeCount = recipes.filter((r) => r.isCustom).length;
|
|
const liteSaveBlocked = !pro && customRecipeCount >= LITE_RECIPE_LIMIT;
|
|
const promptRecipeLimit = () => promptProFeature(`Saving more than ${LITE_RECIPE_LIMIT} recipes`);
|
|
// FAVORITED: ids of the starred recipes. Metadata only — never saved into the
|
|
// session look, so RESET and applying a recipe leave it untouched.
|
|
const [favoriteIds, setFavoriteIds] = useState<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';
|
|
// The overlay is built FROM the adjustments it is laid on, so the preview can
|
|
// ask for the pre-edit one while the finger holds the compare. Built from the
|
|
// live values it always was, it kept painting the EDITED look back over the
|
|
// recipe the peek had swapped in, and neither HIGHLIGHT nor SHADOW (nor EV,
|
|
// CONTRAST, CLARITY) ever came back.
|
|
const meteringOverlay = (a: ColorAdjustments): Partial<ColorAdjustments> => ({
|
|
exposureCompensation: clampAdj((a.exposureCompensation ?? 0) + METER_HL_EV),
|
|
highlight: clampAdj((a.highlight ?? 0) + METER_HL_HIGHLIGHT),
|
|
shadow: clampAdj((a.shadow ?? 0) + METER_HL_SHADOW),
|
|
contrast: clampAdj((a.contrast ?? 0) + METER_HL_CONTRAST),
|
|
clarity: clampAdj((a.clarity ?? 0) + METER_HL_CLARITY),
|
|
});
|
|
const meteringAdj: Partial<ColorAdjustments> | undefined = meteringActive
|
|
? meteringOverlay(adjustments)
|
|
: undefined;
|
|
// Same overlay for the camera EXPORT — the EV dip included: EV is software
|
|
// only (no hardware AE bias anywhere), so preview and file must both apply it.
|
|
const renderAdj: ColorAdjustments = meteringAdj ? { ...adjustments, ...meteringAdj } : adjustments;
|
|
const rawFailRef = useRef(false);
|
|
// Progressive disclosure: a tool chip opens its panel; null = photo hero only.
|
|
const [openTab, setOpenTab] = useState<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);
|
|
// In-app library picker (grid -> full-screen preview -> USE/CANCEL).
|
|
const [libraryPickerVisible, setLibraryPickerVisible] = useState(false);
|
|
// Gear sheet (sound / ratio / metering mode). Camera captures never auto-open
|
|
// the viewer anymore — the thumbnail button does.
|
|
const [settingsVisible, setSettingsVisible] = useState(false);
|
|
// Shutter click: only OFF/ON (no volume API on Android). Applied at capture.
|
|
const [shutterSound, setShutterSound] = useState<ShutterSound>('on');
|
|
// Touch-to-shutter (gear sheet): a plain tap on the live viewfinder locks
|
|
// AE/AF/AWB on the tapped point and, once focus has settled, fires a capture.
|
|
// OFF = a tap only focuses (the historical behaviour).
|
|
const [touchToShutter, setTouchToShutter] = useState(false);
|
|
// Startup behavior: 'default' = PROVIPES neutral at 4:3 camera; 'last' = restore
|
|
// previous session from AsyncStorage. Default is 'default'.
|
|
const [startupMode, setStartupMode] = useState<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: CAPTURE_MODE,
|
|
});
|
|
// Second photo output for RAW DNG sidecars (RATIO panel → RAW on). Kept as
|
|
// a live output only while rawEnabled — CameraX cannot bind two ImageCapture
|
|
// use cases, so attaching both at once would break the session. Attaching a
|
|
// DNG output while RAW is on gives ~one exposure per capture: the JPEG below
|
|
// and this DNG are separate shots taken a few tens of ms apart.
|
|
const rawPhotoOutput = usePhotoOutput({
|
|
containerFormat: 'dng',
|
|
qualityPrioritization: 'quality',
|
|
});
|
|
|
|
// vision-camera may return a raw absolute path on Android; surface a file:// URI.
|
|
const toUri = (path: string) => (/^(file|content|ph):\/\//.test(path) ? path : `file://${path}`);
|
|
|
|
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
|
|
}, []);
|
|
|
|
// 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);
|
|
if (typeof s.touchToShutter === 'boolean') setTouchToShutter(s.touchToShutter);
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to read camera settings:', e);
|
|
}
|
|
settingsHydratedRef.current = true;
|
|
|
|
// Startup mode: 'default' opens camera + PROVIPES film sim (neutral
|
|
// adjustments, DEFAULT_ADJUSTMENTS) at 4:3. 'last' restores the previous
|
|
// session from AsyncStorage; with no saved session it falls back to the
|
|
// same clean default state.
|
|
let startup: StartupMode = 'default';
|
|
try {
|
|
const raw = await AsyncStorage.getItem(STARTUP_MODE_KEY);
|
|
if (raw === 'last') startup = 'last';
|
|
} catch (e) {
|
|
console.warn('Failed to read startup mode:', e);
|
|
}
|
|
setStartupMode(startup);
|
|
|
|
const applyDefaultStartup = () => {
|
|
setMode('camera');
|
|
setLibraryImageUri(null);
|
|
setAspectRatio('4:3');
|
|
const provia = FILM_SIMS.find((f) => f.id === 'sim-provia');
|
|
if (provia) applyRecipe(filmSimToRecipe(provia));
|
|
// Clean camera start: GPS watermark stays OFF until the user opts in.
|
|
setUseGeotag(false);
|
|
};
|
|
|
|
if (startup === 'default') {
|
|
applyDefaultStartup();
|
|
return;
|
|
}
|
|
|
|
let session: any = null;
|
|
try {
|
|
const raw = await AsyncStorage.getItem(SESSION_KEY);
|
|
if (raw) session = JSON.parse(raw);
|
|
} catch (e) {
|
|
console.warn('Failed to read session:', e);
|
|
}
|
|
|
|
let restoredRecipe: Recipe | null = null;
|
|
if (session) {
|
|
// B12: the restored library image may no longer exist on disk
|
|
let imageUri: string | null = session.libraryImageUri || null;
|
|
if (imageUri) {
|
|
try {
|
|
const info = await FileSystem.getInfoAsync(imageUri);
|
|
if (!info.exists) imageUri = null;
|
|
} catch {
|
|
imageUri = null;
|
|
}
|
|
}
|
|
// A saved 'camera' mode is a deliberate choice and outranks a lingering
|
|
// library image: switching modes never clears libraryImageUri, so without
|
|
// this a session closed on the live camera came back up in the viewer.
|
|
if (imageUri && session.mode !== 'camera') {
|
|
setLibraryImageUri(imageUri);
|
|
setMode('library');
|
|
} else {
|
|
// No usable photo (or the user had chosen the camera): open the camera.
|
|
setMode('camera');
|
|
}
|
|
if (session.adjustments)
|
|
setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...session.adjustments });
|
|
if (session.selectedFrame) setSelectedFrame(session.selectedFrame);
|
|
if (session.useGeotag !== undefined) setUseGeotag(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);
|
|
// EVERY knob the recipe carries lands in the panel, backfilled over the
|
|
// defaults so a recipe saved before a knob existed cannot leave that knob on
|
|
// whatever the previous look happened to have.
|
|
setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...recipe.adjustments });
|
|
setSelectedFrame(recipe.frameId || 'none');
|
|
// GPS watermark is the user's own setting (FRAME tab), not part of a
|
|
// preset look: recipes carry a useGeotag field but selecting one must
|
|
// never flip the watermark on/off.
|
|
};
|
|
|
|
// Picking a look — sim or saved recipe — loads the whole thing: identity,
|
|
// every parameter and the frame it was saved with. The panel then shows the
|
|
// values that are actually on screen (and stays editable from there). This
|
|
// used to keep the sliders for sims, which left the panel naming numbers the
|
|
// look no longer had: the sim carried its own adjustments and they were
|
|
// silently dropped. The session restore below is a different case — there the
|
|
// stored adjustments ARE the user's, so it still sets the recipe alone.
|
|
const handleSelectRecipe = (recipe: Recipe) => {
|
|
applyRecipe(recipe);
|
|
};
|
|
|
|
// The recipe exactly as the user picked it. `selectedRecipe` is NOT a safe
|
|
// copy of it: every slider move rewrites its `adjustments` in place
|
|
// (handleUpdateAdjustments), so the canonical values only survive in the
|
|
// film-sim table and in the saved list.
|
|
const pristineRecipe = (recipe: Recipe): Recipe | null => {
|
|
const sim = FILM_SIMS.find((f) => f.id === recipe.id);
|
|
if (sim) return filmSimToRecipe(sim);
|
|
return recipes.find((r) => r.id === recipe.id) || null;
|
|
};
|
|
|
|
// Every edit belongs to the mode and the photo it was made on, so it never
|
|
// crosses into the next session: the sliders go back to the recipe's own
|
|
// values, a frame picked for that still is dropped, and the custom mark —
|
|
// placed against that very photo — is switched back off.
|
|
const resetEditsToRecipe = () => {
|
|
const base = selectedRecipe ? pristineRecipe(selectedRecipe) : null;
|
|
if (base) {
|
|
applyRecipe(base);
|
|
} else {
|
|
setAdjustments({ ...DEFAULT_ADJUSTMENTS });
|
|
setSelectedFrame('none');
|
|
}
|
|
setCustomWm({ ...DEFAULT_CUSTOM_WM });
|
|
setCustomWmApplied({ ...DEFAULT_CUSTOM_WM });
|
|
setGpsWm({ ...DEFAULT_GPS_WM });
|
|
};
|
|
|
|
// 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, touchToShutter })
|
|
).catch(() => {});
|
|
}, [shutterSound, meterMode, rawEnabled, gpsEnabled, touchToShutter]);
|
|
|
|
// Latest-rawEnabled ref so the stable session-error callback below can read
|
|
// the live toggle without re-subscribing the Camera session on every render.
|
|
const rawEnabledRef = useRef(rawEnabled);
|
|
rawEnabledRef.current = rawEnabled;
|
|
// Attaching the DNG output while RAW is on adds a second ImageCapture use
|
|
// case — CameraX historically refuses two per camera. If the session errors
|
|
// in that state, drop RAW (removing the output reconfigures back to the
|
|
// single working JPEG session) instead of leaving a dead camera.
|
|
const handleCameraSessionError = useCallback((error: Error) => {
|
|
console.error('Camera session error:', error);
|
|
if (rawEnabledRef.current && !rawFailRef.current) {
|
|
rawFailRef.current = true;
|
|
setRawEnabled(false);
|
|
Alert.alert(
|
|
'RAW Unavailable',
|
|
'DNG capture could not start on this device, so RAW was turned off. JPEG capture keeps working.'
|
|
);
|
|
}
|
|
}, []);
|
|
|
|
// Save the freshly captured DNG into an album literally named "RAW" (create
|
|
// the album on first use). Failure never blocks the JPEG pipeline.
|
|
const saveRawToAlbum = async (uri: string): Promise<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 (liteSaveBlocked) {
|
|
promptRecipeLimit();
|
|
return;
|
|
}
|
|
if (!selectedRecipe) return;
|
|
const newRecipe = await saveCustomRecipe({
|
|
name,
|
|
baseFilter: selectedRecipe.baseFilter,
|
|
adjustments,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
});
|
|
const list = await getAllRecipes();
|
|
setRecipes(list);
|
|
applyRecipe(newRecipe);
|
|
Alert.alert('Recipe Saved', `"${name}" has been created.`);
|
|
};
|
|
|
|
// TopBar SAVE & SHARE -> EXPORT & SHARE: the look on screen as an encrypted
|
|
// recipe file, handed to the share sheet (and to the phone's storage when
|
|
// the recipient sheet is used to save it).
|
|
const handleExportShare = async (name: string) => {
|
|
try {
|
|
const xml = exportRecipeXml({
|
|
id: 'share',
|
|
name,
|
|
baseFilter: activeRecipe.baseFilter,
|
|
adjustments,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
});
|
|
const file = `${FileSystem.cacheDirectory}${name.replace(/[^\w.-]+/g, '_') || 'recipe'}.rcx.xml`;
|
|
await FileSystem.writeAsStringAsync(file, xml);
|
|
if (!(await Sharing.isAvailableAsync())) {
|
|
Alert.alert('Recipe Exported', `Saved as ${file}`);
|
|
return;
|
|
}
|
|
await Sharing.shareAsync(file, { mimeType: 'application/xml', dialogTitle: `Share recipe "${name}"` });
|
|
} catch (e) {
|
|
Alert.alert('Export failed', e instanceof Error ? e.message : String(e));
|
|
}
|
|
};
|
|
|
|
// PRESETS -> IMPORT: a recipe file shared from another phone, picked from
|
|
// storage, decoded and stored as a normal custom recipe.
|
|
const handleImportRecipe = async () => {
|
|
if (liteSaveBlocked) {
|
|
promptRecipeLimit();
|
|
return;
|
|
}
|
|
try {
|
|
const picked = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true });
|
|
if (picked.canceled) return;
|
|
const xml = await FileSystem.readAsStringAsync(picked.assets[0].uri);
|
|
const created = await saveCustomRecipe(importRecipeXml(xml));
|
|
setRecipes(await getAllRecipes());
|
|
applyRecipe(created);
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
Alert.alert('Recipe Imported', `"${created.name}" has been added.`);
|
|
} catch (e) {
|
|
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
|
|
}
|
|
};
|
|
|
|
// TopBar "SAVE" on a user-created recipe: overwrite it in place, same id.
|
|
const handleOverwriteRecipe = async (name: string) => {
|
|
if (!selectedRecipe) return;
|
|
await updateCustomRecipe(selectedRecipe.id, {
|
|
name,
|
|
baseFilter: selectedRecipe.baseFilter,
|
|
adjustments,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
});
|
|
const list = await getAllRecipes();
|
|
setRecipes(list);
|
|
const updated = list.find((r) => r.id === selectedRecipe.id);
|
|
if (updated) applyRecipe(updated);
|
|
Alert.alert('Recipe Updated', `"${name}" has been saved.`);
|
|
};
|
|
|
|
// The recipe editor (the CREATE form in edit mode) SAVE. The typed name is
|
|
// the switch: unchanged = overwrite that recipe in place (same id, new
|
|
// parameters), new = keep the original and store the edit as its own recipe.
|
|
// The editor only closes first, the confirm dialog comes right after.
|
|
const handleUpdateRecipe = async (recipe: Omit<Recipe, 'id' | 'isCustom'>) => {
|
|
const target = editingRecipe;
|
|
setEditingRecipe(null);
|
|
if (!target) return;
|
|
const name = recipe.name.trim();
|
|
const unchanged = name === target.name.trim();
|
|
if (!unchanged && liteSaveBlocked) {
|
|
promptRecipeLimit();
|
|
return;
|
|
}
|
|
const commit = async () => {
|
|
if (unchanged) {
|
|
await updateCustomRecipe(target.id, { ...recipe, name });
|
|
const list = await getAllRecipes();
|
|
setRecipes(list);
|
|
const updated = list.find((r) => r.id === target.id);
|
|
if (updated) applyRecipe(updated);
|
|
} else {
|
|
const created = await saveCustomRecipe({ ...recipe, name });
|
|
setRecipes(await getAllRecipes());
|
|
applyRecipe(created);
|
|
}
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
Alert.alert(
|
|
unchanged ? 'Recipe Updated' : 'Recipe Saved',
|
|
`"${name}" has been saved.`
|
|
);
|
|
};
|
|
Alert.alert(
|
|
unchanged ? 'Overwrite recipe?' : 'Save as new recipe?',
|
|
unchanged
|
|
? `Replace "${target.name}" with the edited parameters?`
|
|
: `Keep "${target.name}" and save the edit as "${name}"?`,
|
|
[
|
|
{ text: 'CANCEL', style: 'cancel' },
|
|
{ text: unchanged ? 'OVERWRITE' : 'SAVE', onPress: () => { commit(); } },
|
|
],
|
|
);
|
|
};
|
|
|
|
// PRESETS → CREATE quick-input: build a brand-new recipe from the modal's
|
|
// typed fields, persist it, and apply it (the user sees the look immediately).
|
|
const handleCreateRecipe = async (recipe: Omit<Recipe, 'id' | 'isCustom'>) => {
|
|
// Lite composes the recipe and applies it as a throwaway draft so the look
|
|
// can be tried; storing it is what the LITE_RECIPE_LIMIT caps. 'draft' is no
|
|
// free id, so the shutter gate already treats the look as the PRO preset.
|
|
if (liteSaveBlocked) {
|
|
applyRecipe({ ...recipe, id: 'draft', isCustom: false });
|
|
promptRecipeLimit();
|
|
return;
|
|
}
|
|
const newRecipe = await saveCustomRecipe(recipe);
|
|
const list = await getAllRecipes();
|
|
setRecipes(list);
|
|
applyRecipe(newRecipe);
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
};
|
|
|
|
// Deleting is PRO: Lite may create and edit its recipes, it may not drop them.
|
|
const handleDeleteRecipe = async (id: string) => {
|
|
if (!pro) {
|
|
promptProFeature('Deleting recipes');
|
|
return;
|
|
}
|
|
await deleteCustomRecipe(id);
|
|
const list = await getAllRecipes();
|
|
setRecipes(list);
|
|
if (selectedRecipe?.id === id && list.length > 0) {
|
|
applyRecipe(list[0]);
|
|
}
|
|
};
|
|
|
|
// Star/un-star a recipe: optimistic (the chip repaints on tap), persisted in
|
|
// the background. The write is the only failure worth logging — the look is
|
|
// untouched either way.
|
|
const handleToggleFavorite = (id: string) => {
|
|
if (!pro) {
|
|
promptProFeature('Favoriting recipes');
|
|
return;
|
|
}
|
|
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);
|
|
}, []);
|
|
|
|
// OPEN IMAGE: the in-app picker (LibraryPickerModal) does the choosing — a
|
|
// grid, then the photo full screen to pinch/double-tap zoom and swipe through,
|
|
// then USE or CANCEL. The system picker it replaced returned the still on the
|
|
// first tap (no preview) and, on Android 13+, as a redacted cache copy with
|
|
// assetId=null — the grid uri keeps the MediaStore asset id, so with
|
|
// ACCESS_MEDIA_LOCATION granted the file is not redacted and tier-1 EXIF /
|
|
// tier-2 MediaStore both still work.
|
|
const handlePickImage = () => {
|
|
if (isProcessing) return;
|
|
setLibraryPickerVisible(true);
|
|
};
|
|
|
|
const handleLibraryPicked = async (asset: PickedPhotoAsset) => {
|
|
setLibraryPickerVisible(false);
|
|
if (!asset.uri) return;
|
|
setLibraryImageUri(asset.uri);
|
|
// A new photo starts level: the previous photo's turn and fine
|
|
// straighten must not carry over.
|
|
setPhotoRotation(0);
|
|
setPhotoStraighten(0);
|
|
setMode('library');
|
|
// Picking from the camera is the other half of that switch (the camera
|
|
// button is the way back), so the camera's edits must not land on the
|
|
// photo: the picked still starts at the recipe's own values too.
|
|
if (mode !== 'library') resetEditsToRecipe();
|
|
// Clear old GPS — will be replaced if the photo (or device) has location data
|
|
setGpsInfo(null);
|
|
|
|
// Multi-tier GPS resolution: EXIF → MediaLibrary. Never the device's own
|
|
// location — that is where the phone is now, not where the photo was
|
|
// taken, and stamping it just printed the wrong place name on the mark.
|
|
const { 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;
|
|
|
|
// The PRO feature the current look is using, or null while it is Lite-clean.
|
|
// Only the EXPORT consults it: the preview shows every look for free, so the
|
|
// answer is computed where the file is about to be written, not on the chips.
|
|
// PRO unlocks every look, so a paying user has nothing left in use: only a
|
|
// Lite session can trip the two gates below.
|
|
const proLook = pro
|
|
? null
|
|
: proLookInUse({
|
|
recipeId: selectedRecipe?.id,
|
|
frame: selectedFrame,
|
|
customWm: customWmApplied.enabled,
|
|
});
|
|
// The native Kotlin exporter has no text-watermark draw, so a Lite export
|
|
// (which always carries the mark) must take the Skia engine.
|
|
const nativeExport = NATIVE_EXPORT && pro;
|
|
// Shutter/export with a PRO look: nothing is rendered — name the feature and
|
|
// point at the unlock.
|
|
const promptPro = (feature: string) => {
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
|
Alert.alert(
|
|
'PRO LOOK',
|
|
`${feature} can be previewed and edited for free, but exporting it needs PRO. Your own key unlocks it in SETTINGS.`,
|
|
[
|
|
{ text: 'LATER', style: 'cancel' },
|
|
{ text: 'SETTINGS', onPress: () => setSettingsVisible(true) },
|
|
]
|
|
);
|
|
};
|
|
// Saving, starring, spawning: the action IS the feature, so there is no
|
|
// look to preview first — a Lite tap names it and points at the unlock,
|
|
// and nothing else happens.
|
|
const promptProFeature = (feature: string) => {
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
|
Alert.alert(
|
|
'PRO FEATURE',
|
|
`${feature} needs PRO. Your own key unlocks it in SETTINGS.`,
|
|
[
|
|
{ text: 'LATER', style: 'cancel' },
|
|
{ text: 'SETTINGS', onPress: () => setSettingsVisible(true) },
|
|
]
|
|
);
|
|
};
|
|
// Settings unlock: a valid key persists PRO and returns true.
|
|
const handleUnlock = async (key: string): Promise<boolean> => {
|
|
const ok = await activatePro(key);
|
|
if (ok) {
|
|
setPro(true);
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
}
|
|
return ok;
|
|
};
|
|
|
|
// 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 =>
|
|
// Lite: the user's own mark can never reach the file (that look is gated at
|
|
// the shutter); every Lite export carries the RECIPESCAM mark instead.
|
|
!pro
|
|
? LITE_MARK
|
|
: 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;
|
|
|
|
// `burst` marks one frame of a held-shutter burst: the loop already gave the
|
|
// single haptic and the click would machine-gun, so both are muted, and RAW
|
|
// is skipped (a DNG per frame doubles the exposures the burst has to fit).
|
|
const handleCapture = async (opts?: { burst?: boolean }) => {
|
|
if (isProcessing) return;
|
|
|
|
// A PRO look can be composed but not rendered: stop before the shutter, so
|
|
// no capture, no queue entry and no cache file is produced.
|
|
if (proLook) {
|
|
promptPro(proLook);
|
|
return;
|
|
}
|
|
|
|
// Camera mode (Phase 4, option A): real capture through vision-camera.
|
|
if (mode === 'camera') {
|
|
if (!cameraPermissionGranted) {
|
|
Alert.alert('Permission Needed', 'Grant camera permission to take photos.');
|
|
return;
|
|
}
|
|
if (!photoOutput) {
|
|
Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.');
|
|
return;
|
|
}
|
|
// One capture in flight at a time — the shutter button, a viewfinder tap
|
|
// and a burst frame all land here.
|
|
if (captureBusyRef.current) return;
|
|
captureBusyRef.current = true;
|
|
if (!opts?.burst) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
try {
|
|
// A photo taken now is at the device's current spot — resolve device GPS
|
|
// fresh instead of reusing GPS left over from a previously loaded photo.
|
|
let captureGps = gpsInfo && gpsInfo.source === 'device' ? gpsInfo : null;
|
|
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
|
|
let tSensor = 0;
|
|
// 0.5x is a native Camera2 session on the hidden ultra-wide lens, not a
|
|
// CameraX zoom ratio: no CameraX session is bound while it is live, so
|
|
// the still has to come from the native module — and Camera2 fires no
|
|
// shutter sound of its own.
|
|
const ultraWideShot = viewfinderRef.current?.isUltraWide?.() === true;
|
|
let filePath: string;
|
|
if (ultraWideShot) {
|
|
filePath = await UltraWide.captureAsync();
|
|
tSensor = Date.now();
|
|
} else {
|
|
filePath = (
|
|
await photoOutput.capturePhotoToFile(
|
|
{ enableShutterSound: shutterSound === 'on' && !opts?.burst },
|
|
{ onDidCapturePhoto: () => { tSensor = Date.now(); } },
|
|
)
|
|
).filePath;
|
|
}
|
|
const sourceUri = toUri(filePath);
|
|
|
|
// RAW DNG sidecar: fire the second exposure immediately after the JPEG
|
|
// so the two shots stay a few tens of ms apart, then file it into the
|
|
// "RAW" album without blocking the JPEG export below.
|
|
if (rawEnabled && rawPhotoOutput && !opts?.burst && !ultraWideShot) {
|
|
try {
|
|
const rawFile = await rawPhotoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {});
|
|
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 nativeExport
|
|
? 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 = nativeExport && !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.');
|
|
}
|
|
};
|
|
|
|
// Held-shutter burst: press and hold the shutter, frames keep coming until
|
|
// the finger lifts. Every frame is a normal capture (GPS resolve, RAW rules,
|
|
// background render enqueue), so a burst photo is indistinguishable from a
|
|
// tapped one apart from the muted haptic/sound. The `await` IS the pacing: a
|
|
// frame cannot start before the previous one is on disk, which is why no
|
|
// timer is needed (and why 'speed' buys nothing extra here).
|
|
// ponytail: BURST_MAX is a runaway guard for a lost press-out (responder
|
|
// terminated mid-hold); raise it if bursts longer than 30 frames are wanted.
|
|
const BURST_MAX = 30;
|
|
const burstRef = useRef(false);
|
|
const handleBurstStart = () => {
|
|
if (mode !== 'camera' || burstRef.current) return;
|
|
if (proLook) {
|
|
promptPro(proLook);
|
|
return;
|
|
}
|
|
burstRef.current = true;
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
void (async () => {
|
|
for (let i = 0; i < BURST_MAX && burstRef.current; i++) {
|
|
await handleCapture({ burst: true });
|
|
}
|
|
burstRef.current = false;
|
|
})();
|
|
};
|
|
const handleBurstStop = () => {
|
|
burstRef.current = false;
|
|
};
|
|
|
|
// Switching back to camera: drop GPS that belongs to a library photo, so the
|
|
// live view (and any capture) never stamps a stale location.
|
|
const handleSetMode = (next: 'camera' | 'library') => {
|
|
if (next === mode) return;
|
|
if (next === 'camera' && gpsInfo && gpsInfo.source !== 'device') {
|
|
setGpsInfo(null);
|
|
}
|
|
// The deck panels belong to one mode only (FRAME exists in library alone,
|
|
// and the WATERMARK panel is the watermark's edit session), so every switch
|
|
// closes them: no frame chip row over the live preview, and no watermark
|
|
// placement layer left eating the camera's tap-to-focus.
|
|
setOpenTab(null);
|
|
setOpenParamKey(null);
|
|
resetEditsToRecipe();
|
|
// Camera mode has no FRAME picker to undo a frame, so even a recipe that
|
|
// carries one must not follow the user onto the live camera (and into the
|
|
// capture, which burns the frame in).
|
|
if (next === 'camera') setSelectedFrame('none');
|
|
setMode(next);
|
|
};
|
|
|
|
// Append a finished export (camera capture or library export) to the session
|
|
// photo history. Returned entry id lets callers delete by id.
|
|
const pushPhoto = (p: Omit<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) => {
|
|
if (tab === 'favorites' && !pro) {
|
|
promptProFeature('The FAVORITED tab');
|
|
return;
|
|
}
|
|
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={meteringActive ? meteringOverlay(shownAdjustments) : undefined}
|
|
imageAdjustTarget={imageAdjustTarget}
|
|
onComparePeek={peekArmed ? handleComparePeek : undefined}
|
|
// Touch-to-shutter: tap the live image -> focus locks -> capture.
|
|
onTouchShutter={touchToShutter ? handleCapture : 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
|
|
}
|
|
onExportShare={handleExportShare}
|
|
/>
|
|
|
|
{/* 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}
|
|
onImportRecipe={handleImportRecipe}
|
|
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} />
|
|
|
|
{/* Library picker (grid -> zoomable preview -> USE / CANCEL) */}
|
|
<LibraryPickerModal
|
|
visible={libraryPickerVisible}
|
|
onCancel={() => setLibraryPickerVisible(false)}
|
|
onSelect={handleLibraryPicked}
|
|
/>
|
|
|
|
<CameraControls
|
|
lastPhotoUri={lastPhotoUri}
|
|
onCapture={handleCapture}
|
|
onBurstStart={mode === 'camera' ? handleBurstStart : undefined}
|
|
onBurstStop={handleBurstStop}
|
|
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}
|
|
touchToShutter={touchToShutter}
|
|
onTouchToShutter={setTouchToShutter}
|
|
gpsEnabled={gpsEnabled}
|
|
onGpsEnabled={handleToggleGps}
|
|
startupMode={startupMode}
|
|
onStartupMode={handleStartupMode}
|
|
pro={pro}
|
|
onUnlock={handleUnlock}
|
|
/>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|