cbd0178cda
The rail gains an HSL tab between FX and FRAME, and the tab opens the mixer ported from the web studio: eight hue bands, each with hue/sat/lum knobs on a -10..+10 ruler, plus three whole-image moves behind an IMAGE divider. Band weights are linear tents anchored on HSL_BANDS and summing to 1 at every hue, so neighbouring bands hand over at exactly half strength and no hue falls in a gap. Pixels under 8% saturation are gated out of the weights (rgb2hsl calls a neutral red, so an ungated one would ride the RED band), while the whole-image LUM move is added after the gate and still lifts a picture that -SAT has pulled to grey. The mixer runs last in TONE_SKSL, so a band edit is judged on the colour the user actually sampled. The eyedropper is library-only: PICK arms a layer over the picture, a tap reads the rendered pixel off the canvas, points the ruler at the nearest band and hangs a panel carrying that band's three knobs. A live preview has no pixel to read, so in camera mode the chip greys out - the mixer itself still runs there, which is why the camera worklet's tone buffer had to grow from 10 slots to 40: a missed slot would have pushed NaN into every uniform and taken the whole tone pass down, not just HSL. HSL is a LITE feature (the web studio builds it for guests too), and MONOCHROME switches the mixer off outright - a mono stock has no hue to select. Checked on a Xiaomi thor against an 8-band chart: +10 whole-image HUE rotates all eight bands by +30 deg, -10 on one band's SAT flattens that band and leaves the other seven alone, and a whole-image -SAT drops the camera preview's mean chroma 13.7 -> 0.4 with +LUM still lifting it 116 -> 166. Cut as 1.2.4 / versionCode 6.
2173 lines
96 KiB
TypeScript
2173 lines
96 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { View, StatusBar, Alert, ActivityIndicator, Text, AppState, Platform, Keyboard } from 'react-native';
|
|
import { useCameraDevice, useCameraPermission, usePhotoOutput } from 'react-native-vision-camera';
|
|
import * as DocumentPicker from 'expo-document-picker';
|
|
import * as Sharing from 'expo-sharing';
|
|
import * as FileSystem from 'expo-file-system/legacy';
|
|
import * as MediaLibraryLegacy from 'expo-media-library/legacy';
|
|
import * as Haptics from 'expo-haptics';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
|
|
import TopBar from './src/components/TopBar';
|
|
import Viewfinder, { type ViewfinderHandle } from './src/components/Viewfinder';
|
|
import AdjustmentPanel, { HslPickPanel } 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 QrScanModal from './src/components/QrScanModal';
|
|
import ProModal from './src/components/ProModal';
|
|
|
|
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, FlashMode, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT, HslBand, HslBandId } 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 { HSL_BANDS, nearestHslBand, rgbToHsl } from './src/utils/colorUtils';
|
|
import { getCurrentGPS, requestLocationPermissions, reverseGeocode, withDeadline } from './src/utils/locationUtils';
|
|
// Types are erased at build time, so this one is free.
|
|
import type { PickedPhotoAsset } from './src/utils/photoGps';
|
|
import { PARAM_DEFS } from './src/utils/paramDefs';
|
|
import { LITE_FAVORITE_LIMIT, LITE_MARK, LITE_RECIPE_LIMIT, proLookInUse } from './src/utils/entitlement';
|
|
import { initProUnlock, useProUnlock } from './src/utils/proUnlock';
|
|
import UltraWide from './modules/recipescam-ultrawide';
|
|
import RecipescamExport from './modules/recipescam-export';
|
|
import VolumeShutter from './modules/recipescam-volume';
|
|
|
|
// @ts-ignore
|
|
import './global.css';
|
|
|
|
// Shallow value diff over the union of both objects' keys: undefined and a
|
|
// missing key both read as "no value", so an old session without the newer
|
|
// optional knobs is not reported as an edit.
|
|
const differs = <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.
|
|
// How long a shutter may wait for a GPS fix before exporting without it. The
|
|
// photo is never held hostage by the receiver: indoors a Balanced fix takes
|
|
// 20s+ (measured 25s on the V2430), which is the whole reason a capture used to
|
|
// take half a minute to reach the gallery. The next shot picks the fix up.
|
|
const GPS_WAIT_MS = 2000;
|
|
|
|
// The photo pipeline (Skia, the frame pack, the filter maths) and the recipe
|
|
// XML codec run behind a shutter press or a share sheet, never on the first
|
|
// frame, so they are loaded on demand instead of sitting in the cold-start
|
|
// module graph. Same two functions the eager imports used to bind; wait: only
|
|
// the *renderers* moved — nothing about an export changed.
|
|
const loadRenderer = (native: boolean) =>
|
|
native
|
|
? import('./src/utils/nativeExport').then((m) => m.processAndExportPhotoNative)
|
|
: import('./src/utils/exportEngine').then((m) => m.processAndExportPhoto);
|
|
const CAPTURE_MODE =
|
|
process.env.EXPO_PUBLIC_CAPTURE_MODE === 'speed' || process.env.EXPO_PUBLIC_CAPTURE_MODE === 'quality'
|
|
? process.env.EXPO_PUBLIC_CAPTURE_MODE
|
|
: 'balanced';
|
|
|
|
export default function App() {
|
|
const [mode, setMode] = useState<'camera' | 'library'>('camera');
|
|
// Back/front lens the live viewfinder uses; flipping is transient UI state
|
|
// (not persisted in sessions — startup always opens on the back camera).
|
|
const [cameraFacing, setCameraFacing] = useState<'back' | 'front'>('back');
|
|
// Imperative handle into the Viewfinder camera controller: reads the live
|
|
// viewfinder state at capture/export time (framed-window zoom, crop rect,
|
|
// horizon). There is no EV flush to do: the hardware AE bias is never
|
|
// touched — setExposureBias re-locks AE and flashes the Xiaomi preview.
|
|
const viewfinderRef = useRef<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);
|
|
// Bottom edge of the floating top strip -> the library photo starts there; a
|
|
// ratio band peeking under the header is a band the user cannot see.
|
|
const [topInset, setTopInset] = useState(0);
|
|
// Library photo rotation (FRAME tab): the quarter turn in degrees clockwise
|
|
// plus the fine STRAIGHTEN offset. Both feed the preview source and the
|
|
// export alike, the straighten angle applied on top of the turn.
|
|
const [photoRotation, setPhotoRotation] = useState<0 | 90 | 180 | 270>(0);
|
|
const [photoStraighten, setPhotoStraighten] = useState(0);
|
|
useEffect(() => {
|
|
const show = Keyboard.addListener('keyboardDidShow', (e) => setKeyboardHeight(e.endCoordinates.height));
|
|
const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardHeight(0));
|
|
return () => {
|
|
show.remove();
|
|
hide.remove();
|
|
};
|
|
}, []);
|
|
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
|
// LITE (free) vs PRO: the PRO apk is born owned, the free one buys the tier
|
|
// through Google Play. Runtime state, read here and passed down.
|
|
const pro = useProUnlock().owned;
|
|
// The paywall sheet: opened by the SETTINGS plan row and by every alert that
|
|
// names a PRO feature, so the refusal always comes with a way to pay.
|
|
const [showPro, setShowPro] = useState(false);
|
|
// Ask the store what this account owns, once, at startup. Until it answers,
|
|
// the cached answer stands (see src/utils/proUnlock.ts).
|
|
useEffect(() => {
|
|
initProUnlock();
|
|
}, []);
|
|
// Lite stores up to LITE_RECIPE_LIMIT recipes of its own; past that the save
|
|
// paths ask for PRO. Deleting one is PRO as well, so a Lite list only grows.
|
|
const customRecipeCount = recipes.filter((r) => r.isCustom).length;
|
|
const liteSaveBlocked = !pro && customRecipeCount >= LITE_RECIPE_LIMIT;
|
|
const promptRecipeLimit = () => promptProFeature(`Saving more than ${LITE_RECIPE_LIMIT} recipes`);
|
|
// FAVORITED: ids of the starred recipes. Metadata only — never saved into the
|
|
// session look, so RESET and applying a recipe leave it untouched.
|
|
const [favoriteIds, setFavoriteIds] = useState<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 });
|
|
|
|
// HSL mixer: the band ruler and the eyedropper are shared by the chip row
|
|
// (AdjustmentPanel) and by the pick layer over the picture (Viewfinder), so
|
|
// they live here.
|
|
const [hslBand, setHslBand] = useState<HslBandId>('red');
|
|
const [picking, setPicking] = useState(false);
|
|
const [hslSample, setHslSample] = useState<{ r: number; g: number; b: number } | null>(null);
|
|
// Where on the picture the sample was read: fractions of the image, the
|
|
// floating panel's anchor.
|
|
const [hslPickedAt, setHslPickedAt] = useState<{ fx: number; fy: number } | null>(null);
|
|
|
|
const [selectedFrame, setSelectedFrame] = useState<FrameId>('none');
|
|
// PRO only: the GPS stamp is a paid look, so a LITE session starts without
|
|
// it and `handleToggleGeotag` refuses to turn it on.
|
|
const [useGeotag, setUseGeotag] = useState(pro);
|
|
// GPS master switch (SETTINGS -> GPS): may the app read the device's
|
|
// location at all? ON resolves a fix at capture and reverse-geocodes it;
|
|
// OFF reads nothing. The GPS WATERMARK chip is separate — it only decides
|
|
// whether a resolved location is printed on the photo.
|
|
const [gpsEnabled, setGpsEnabled] = useState(true);
|
|
const [gpsInfo, setGpsInfo] = useState<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) => {
|
|
// Typing coalesces into one step per pause; a chip (colour, size, ON/OFF)
|
|
// is a step of its own even when two of them land back to back.
|
|
commit(wm.text !== customWm.text, () => {
|
|
setCustomWm(wm);
|
|
if (!wm.enabled) setCustomWmApplied({ ...DEFAULT_CUSTOM_WM });
|
|
});
|
|
};
|
|
// Leaving the watermark editor = closing its panel. The mark keeps capturing
|
|
// image touches ONLY while that panel is open (Viewfinder's wmArmed), so this
|
|
// is what hands the photo back to tap-to-focus / pinch / double-tap zoom.
|
|
const closeWatermarkEditor = () => {
|
|
setOpenTab(null);
|
|
setOpenParamKey(null);
|
|
};
|
|
// ACCEPT: the draft becomes the mark the export burns in, then the editor
|
|
// closes so the image stops being a placement surface.
|
|
const handleAcceptCustomWm = () => {
|
|
commit(false, () => setCustomWmApplied({ ...customWm }));
|
|
closeWatermarkEditor();
|
|
};
|
|
// CANCEL: drop every un-accepted edit and fall back to the last ACCEPTED mark
|
|
// (none at all when nothing was ever accepted), then close exactly like
|
|
// ACCEPT — the escape hatch a running session previously lacked.
|
|
const handleCancelCustomWm = () => {
|
|
commit(false, () => setCustomWm({ ...customWmApplied }));
|
|
closeWatermarkEditor();
|
|
};
|
|
// Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar.
|
|
const [aspectRatio, setAspectRatio] = useState<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);
|
|
// QR sheet: reads a recipe link off a screen, so the viewfinder camera behind
|
|
// it is paused while it is up (one lens, two camera libraries).
|
|
const [scanVisible, setScanVisible] = 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');
|
|
// LED flash mode. Capture-time only (no live torch): 'off' keeps today's
|
|
// behaviour, 'auto' lets the pipeline decide, 'on' always fires. Applied to
|
|
// the still, so the preview itself never changes brightness.
|
|
const [flashMode, setFlashMode] = useState<FlashMode>('off');
|
|
// Touch-to-shutter (gear sheet): a plain tap on the live viewfinder locks
|
|
// AE/AF/AWB on the tapped point and, once focus has settled, fires a capture.
|
|
// OFF = a tap only focuses (the historical behaviour).
|
|
const [touchToShutter, setTouchToShutter] = useState(false);
|
|
// Startup behavior: 'default' = PROVIPES neutral at 4:3 camera; 'last' = restore
|
|
// previous session from AsyncStorage. Default is 'default'.
|
|
const [startupMode, setStartupMode] = useState<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);
|
|
// Cold-start shutter gate: the Viewfinder reports when the CameraX session
|
|
// is actually bound (onStarted/onStopped). A shutter press landing before
|
|
// that is QUEUED, not dropped — the built-in camera answers the first tap
|
|
// too, and a press into a dead session produced no file at all.
|
|
const cameraReadyRef = useRef(false);
|
|
const pendingShotRef = useRef(false);
|
|
// FIFO background render queue: processAndExportPhoto (Skia render + gallery
|
|
// save, ~1s) runs OFF the shutter path, one at a time, so the next photo can
|
|
// be taken immediately. FIFO keeps history order == shutter order. Renders
|
|
// are heavy on the JS thread but yield between awaits — UI keeps responding.
|
|
const exportQueueRef = useRef<Promise<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();
|
|
// The lens the flash control belongs to. CameraX throws when asked for
|
|
// 'on'/'auto' on a device with no flash unit (the front lens, most tablets),
|
|
// so the control is only offered while this reads true.
|
|
const flashAvailable = useCameraDevice(cameraFacing)?.hasFlash ?? false;
|
|
// Full-resolution photo output — attached to the live <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}`);
|
|
|
|
// Resolved by the mount effect just below, awaited by the share intake further
|
|
// down: a share that cold-starts the app must land AFTER the session restore,
|
|
// or the restored libraryImageUri/mode would replace the shared photo — and by
|
|
// then the intent is consumed, so the photo is unreachable for good.
|
|
const sessionInitRef = useRef<Promise<void> | null>(null);
|
|
|
|
useEffect(() => {
|
|
// B5: run a single sequenced init — restore the session first, then fall
|
|
// back to the first recipe only when no recipe was restored.
|
|
// The restored look is step zero: BACK can never walk behind the load.
|
|
sessionInitRef.current = initSession().then(clearHistory);
|
|
// Do NOT auto-load device GPS here — it would override photo GPS on library mode
|
|
}, []);
|
|
|
|
// A photo picked while the GPS chip was OFF kept its coordinates but no place
|
|
// name (the geocode only ran when the chip was already on), and toggleGeotag
|
|
// skips GPS loading entirely when coordinates exist — so turning the chip on
|
|
// showed "STREET VIEW". Resolve the name whenever the mark needs one.
|
|
useEffect(() => {
|
|
if (!gpsEnabled || !gpsInfo || gpsInfo.locality) return;
|
|
let stale = false;
|
|
reverseGeocode(gpsInfo.latitude, gpsInfo.longitude).then((name) => {
|
|
if (!stale && name) setGpsInfo((g) => (g && !g.locality ? { ...g, locality: name } : g));
|
|
});
|
|
return () => {
|
|
stale = true;
|
|
};
|
|
}, [gpsEnabled, gpsInfo]);
|
|
|
|
// Warm the fix up at launch instead of at the shutter. A fix taken here is
|
|
// what the first photo stamps, so the capture path normally has nothing to
|
|
// wait for — the same head start the built-in camera gets from its own
|
|
// always-on location client. GPS_WAIT_MS below is the backstop when it is not
|
|
// ready yet.
|
|
useEffect(() => {
|
|
if (!gpsEnabled) return;
|
|
let stale = false;
|
|
getCurrentGPS().then((info) => {
|
|
if (!stale && info) setGpsInfo((g) => g ?? { ...info, source: 'device' as const });
|
|
});
|
|
return () => {
|
|
stale = true;
|
|
};
|
|
}, [gpsEnabled]);
|
|
|
|
// Save/restore session on app lifecycle changes
|
|
useEffect(() => {
|
|
const subscription = AppState.addEventListener('change', async (nextAppState) => {
|
|
if (nextAppState === 'background' || nextAppState === 'inactive') {
|
|
const session = {
|
|
mode,
|
|
libraryImageUri,
|
|
adjustments,
|
|
selectedFrame,
|
|
useGeotag,
|
|
aspectRatio,
|
|
cropRatio,
|
|
cropRect,
|
|
cropApplied,
|
|
meterMode,
|
|
shutterSound,
|
|
selectedRecipeId: selectedRecipe?.id || null,
|
|
gpsInfo,
|
|
customWm,
|
|
gpsWm,
|
|
};
|
|
try {
|
|
await AsyncStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
|
} catch (e) {
|
|
console.warn('Failed to save session:', e);
|
|
}
|
|
}
|
|
});
|
|
return () => subscription.remove();
|
|
}, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, customWm, gpsWm, aspectRatio, cropRatio, cropRect, cropApplied, meterMode, shutterSound]);
|
|
|
|
// Guards the camera-settings persist effect below: until initSession has
|
|
// read the stored values, changes are just the initial defaults — writing
|
|
// them would overwrite the saved settings (async restore loses the race
|
|
// against the mount effect).
|
|
const settingsHydratedRef = useRef(false);
|
|
|
|
const initSession = async () => {
|
|
const list = await getAllRecipes();
|
|
setRecipes(list);
|
|
setFavoriteIds(await getFavoriteIds());
|
|
|
|
// Device/camera settings restore first: they apply under BOTH startup
|
|
// modes ('default' resets only the look, not these hardware choices).
|
|
try {
|
|
const raw = await AsyncStorage.getItem(CAMERA_SETTINGS_KEY);
|
|
if (raw) {
|
|
const s = JSON.parse(raw);
|
|
if (s.shutterSound === 'on' || s.shutterSound === 'off') setShutterSound(s.shutterSound);
|
|
if (s.flashMode === 'off' || s.flashMode === 'auto' || s.flashMode === 'on') setFlashMode(s.flashMode);
|
|
if (s.meterMode === 'evaluative' || s.meterMode === 'highlight') setMeterMode(s.meterMode);
|
|
if (typeof s.rawEnabled === 'boolean') setRawEnabled(s.rawEnabled);
|
|
if (typeof s.gpsEnabled === 'boolean') setGpsEnabled(s.gpsEnabled);
|
|
if (typeof s.touchToShutter === 'boolean') setTouchToShutter(s.touchToShutter);
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to read camera settings:', e);
|
|
}
|
|
settingsHydratedRef.current = true;
|
|
|
|
// Startup mode: 'default' opens camera + PROVIPES film sim (neutral
|
|
// adjustments, DEFAULT_ADJUSTMENTS) at 4:3. 'last' restores the previous
|
|
// session from AsyncStorage; with no saved session it falls back to the
|
|
// same clean default state.
|
|
let startup: StartupMode = 'default';
|
|
try {
|
|
const raw = await AsyncStorage.getItem(STARTUP_MODE_KEY);
|
|
if (raw === 'last') startup = 'last';
|
|
} catch (e) {
|
|
console.warn('Failed to read startup mode:', e);
|
|
}
|
|
setStartupMode(startup);
|
|
|
|
const applyDefaultStartup = () => {
|
|
setMode('camera');
|
|
setLibraryImageUri(null);
|
|
setAspectRatio('4:3');
|
|
const provia = FILM_SIMS.find((f) => f.id === 'sim-provia');
|
|
if (provia) applyRecipe(filmSimToRecipe(provia));
|
|
// Clean camera start: GPS watermark stays OFF until the user opts in.
|
|
setUseGeotag(false);
|
|
};
|
|
|
|
if (startup === 'default') {
|
|
applyDefaultStartup();
|
|
return;
|
|
}
|
|
|
|
let session: any = null;
|
|
try {
|
|
const raw = await AsyncStorage.getItem(SESSION_KEY);
|
|
if (raw) session = JSON.parse(raw);
|
|
} catch (e) {
|
|
console.warn('Failed to read session:', e);
|
|
}
|
|
|
|
let restoredRecipe: Recipe | null = null;
|
|
if (session) {
|
|
// B12: the restored library image may no longer exist on disk
|
|
let imageUri: string | null = session.libraryImageUri || null;
|
|
if (imageUri) {
|
|
try {
|
|
const info = await FileSystem.getInfoAsync(imageUri);
|
|
if (!info.exists) imageUri = null;
|
|
} catch {
|
|
imageUri = null;
|
|
}
|
|
}
|
|
// A saved 'camera' mode is a deliberate choice and outranks a lingering
|
|
// library image: switching modes never clears libraryImageUri, so without
|
|
// this a session closed on the live camera came back up in the viewer.
|
|
if (imageUri && session.mode !== 'camera') {
|
|
setLibraryImageUri(imageUri);
|
|
setMode('library');
|
|
} else {
|
|
// No usable photo (or the user had chosen the camera): open the camera.
|
|
setMode('camera');
|
|
}
|
|
if (session.adjustments)
|
|
setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...session.adjustments });
|
|
if (session.selectedFrame) setSelectedFrame(session.selectedFrame);
|
|
if (session.useGeotag !== undefined) setUseGeotag(pro && session.useGeotag);
|
|
if (session.aspectRatio === 'full' || session.aspectRatio === '4:3' || session.aspectRatio === '3:2') setAspectRatio(session.aspectRatio);
|
|
if (session.cropRatio) setCropRatio(session.cropRatio);
|
|
if (session.cropRect && typeof session.cropRect === 'object') setCropRect({ ...DEFAULT_CROP_RECT, ...session.cropRect });
|
|
// Committed crop: only trust a value that still matches the restored
|
|
// ratio, so a stale snapshot can never show a crop it never framed.
|
|
if (session.cropApplied && session.cropApplied === session.cropRatio) setCropApplied(session.cropApplied);
|
|
// NOTE: meterMode/shutterSound/rawEnabled are NOT restored from the
|
|
// session snapshot here — the session file is only written on background,
|
|
// so it can be stale after a force-kill. CAMERA_SETTINGS_KEY above
|
|
// persists them on every change and is authoritative for these three.
|
|
if (session.gpsInfo) setGpsInfo(session.gpsInfo);
|
|
if (session.customWm && typeof session.customWm === 'object') {
|
|
// Restore defensively: a session saved by an older build may miss the
|
|
// field entirely or hold a malformed shape.
|
|
const raw = session.customWm as Partial<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: pro && typeof raw.font === 'string' ? raw.font : null,
|
|
});
|
|
}
|
|
if (session.gpsWm && typeof session.gpsWm === 'object') {
|
|
// Style chips only — the placement fractions are left alone (restoring
|
|
// a stale x/y would silently move the stamp the user just dragged).
|
|
const raw = session.gpsWm as Partial<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) => {
|
|
commit(false, () => {
|
|
setSelectedRecipe(recipe);
|
|
// EVERY knob the recipe carries lands in the panel, backfilled over the
|
|
// defaults so a recipe saved before a knob existed cannot leave that knob
|
|
// on whatever the previous look happened to have.
|
|
setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...recipe.adjustments });
|
|
setSelectedFrame(recipe.frameId || 'none');
|
|
});
|
|
// GPS watermark is the user's own setting (FRAME tab), not part of a
|
|
// preset look: recipes carry a useGeotag field but selecting one must
|
|
// never flip the watermark on/off.
|
|
};
|
|
|
|
// Picking a look — sim or saved recipe — loads the whole thing: identity,
|
|
// every parameter and the frame it was saved with. The panel then shows the
|
|
// values that are actually on screen (and stays editable from there). This
|
|
// used to keep the sliders for sims, which left the panel naming numbers the
|
|
// look no longer had: the sim carried its own adjustments and they were
|
|
// silently dropped. The session restore below is a different case — there the
|
|
// stored adjustments ARE the user's, so it still sets the recipe alone.
|
|
const handleSelectRecipe = (recipe: Recipe) => {
|
|
applyRecipe(recipe);
|
|
};
|
|
|
|
// The recipe exactly as the user picked it. `selectedRecipe` is NOT a safe
|
|
// copy of it: every slider move rewrites its `adjustments` in place
|
|
// (handleUpdateAdjustments), so the canonical values only survive in the
|
|
// film-sim table and in the saved list.
|
|
const pristineRecipe = (recipe: Recipe): Recipe | null => {
|
|
const sim = FILM_SIMS.find((f) => f.id === recipe.id);
|
|
if (sim) return filmSimToRecipe(sim);
|
|
return recipes.find((r) => r.id === recipe.id) || null;
|
|
};
|
|
|
|
// Every edit belongs to the mode and the photo it was made on, so it never
|
|
// crosses into the next session: the sliders go back to the recipe's own
|
|
// values, a frame picked for that still is dropped, and the custom mark —
|
|
// placed against that very photo — is switched back off.
|
|
const resetEditsToRecipe = () => {
|
|
const base = selectedRecipe ? pristineRecipe(selectedRecipe) : null;
|
|
if (base) {
|
|
applyRecipe(base);
|
|
} else {
|
|
setAdjustments({ ...DEFAULT_ADJUSTMENTS });
|
|
setSelectedFrame('none');
|
|
}
|
|
setCustomWm({ ...DEFAULT_CUSTOM_WM });
|
|
setCustomWmApplied({ ...DEFAULT_CUSTOM_WM });
|
|
setGpsWm({ ...DEFAULT_GPS_WM });
|
|
// A look belongs to the photo and the mode it was edited on: the next load
|
|
// starts a fresh history, even when the values come back the same.
|
|
clearHistory();
|
|
};
|
|
|
|
// RESET (panel chip, any tab): the state a default start would leave — the
|
|
// startup look, every parameter back to neutral, frame off and both
|
|
// watermarks back to their defaults. Same pieces as resetEditsToRecipe, but
|
|
// the look goes back to the app's own default rather than the recipe's.
|
|
// A RESET on an already-clean look is not a step: BACK would have nothing
|
|
// to put back and would burn a press on a no-op.
|
|
const handleResetAll = () => {
|
|
if (resetDirty) commit(false, resetLookInPlace);
|
|
};
|
|
const resetLookInPlace = () => {
|
|
const provia = FILM_SIMS.find((f) => f.id === 'sim-provia');
|
|
if (provia) {
|
|
applyRecipe(filmSimToRecipe(provia));
|
|
} else {
|
|
setSelectedRecipe(null);
|
|
setAdjustments({ ...DEFAULT_ADJUSTMENTS });
|
|
setSelectedFrame('none');
|
|
}
|
|
setCustomWm({ ...DEFAULT_CUSTOM_WM });
|
|
setCustomWmApplied({ ...DEFAULT_CUSTOM_WM });
|
|
setGpsWm({ ...DEFAULT_GPS_WM });
|
|
// The rest of the look's state, each back to the value a clean start ships
|
|
// with: the GPS stamp off, both watermark rotations upright, the library
|
|
// photo and its wall frame back to portrait. ponytail: mode + aspect stay
|
|
// put — they frame the shot you are working on, not the look.
|
|
setUseGeotag(false);
|
|
setWmRotation(0);
|
|
setPhotoRotation(0);
|
|
setPhotoStraighten(0);
|
|
setWallLandscape(false);
|
|
setCropRatio('none');
|
|
setCropApplied(null);
|
|
setCropRect(DEFAULT_CROP_RECT);
|
|
};
|
|
|
|
// RESET lights up while the look differs from what handleResetAll puts back —
|
|
// every clause here has a matching setter there, or the star never goes out.
|
|
// The clean-start values: the startup sim, neutral knobs, no frame, no crop,
|
|
// both marks at their defaults, GPS stamp off, nothing rotated.
|
|
const resetDirty =
|
|
(selectedRecipe?.id ?? 'sim-provia') !== 'sim-provia' ||
|
|
differs(adjustments, DEFAULT_ADJUSTMENTS) ||
|
|
selectedFrame !== 'none' ||
|
|
differs(customWm, DEFAULT_CUSTOM_WM) ||
|
|
differs(customWmApplied, DEFAULT_CUSTOM_WM) ||
|
|
differs(gpsWm, DEFAULT_GPS_WM) ||
|
|
useGeotag ||
|
|
wmRotation !== 0 ||
|
|
photoRotation !== 0 ||
|
|
photoStraighten !== 0 ||
|
|
wallLandscape ||
|
|
cropRatio !== 'none' ||
|
|
differs(cropRect, DEFAULT_CROP_RECT);
|
|
|
|
// ---- Look history: BACK (undo) + PEEK (hold to compare) ------------------
|
|
// Every edit the panel can make is a step: PRESETS/RECIPES, LIGHT, WB, FX and
|
|
// FRAME (frame, crop, turn, straighten, both watermarks, the GPS stamp). BACK
|
|
// pops one step; PEEK — a hold on the picture — shows the
|
|
// look one step back, with every tab's values travelling with it. Neither
|
|
// touches the stored values, the RECIPE or the export: undo writes them, peek
|
|
// only shows them. The history is seeded at every load, so BACK stops there.
|
|
type Look = {
|
|
recipe: Recipe | null;
|
|
adjustments: ColorAdjustments;
|
|
frame: FrameId;
|
|
cropRatio: CropRatio;
|
|
cropApplied: CropRatio | null;
|
|
cropRect: CropRect;
|
|
photoRotation: 0 | 90 | 180 | 270;
|
|
photoStraighten: number;
|
|
wallLandscape: boolean;
|
|
useGeotag: boolean;
|
|
wmRotation: number;
|
|
customWm: CustomWatermark;
|
|
customWmApplied: CustomWatermark;
|
|
gpsWm: GpsWatermark;
|
|
};
|
|
const lookNow = (): Look => ({
|
|
recipe: selectedRecipe,
|
|
adjustments,
|
|
frame: selectedFrame,
|
|
cropRatio,
|
|
cropApplied,
|
|
cropRect,
|
|
photoRotation,
|
|
photoStraighten,
|
|
wallLandscape,
|
|
useGeotag,
|
|
wmRotation,
|
|
customWm,
|
|
customWmApplied,
|
|
gpsWm,
|
|
});
|
|
const lookRef = useRef<Look | null>(null);
|
|
const pastRef = useRef<Look[]>([]);
|
|
const [undoDepth, setUndoDepth] = useState(0);
|
|
const [peekLook, setPeekLook] = useState<Look | null>(null);
|
|
// A gesture is one step: a slider drag (or a run of keystrokes) inside this
|
|
// gap stays the step it started as, so BACK undoes the whole gesture rather
|
|
// than one event of it.
|
|
const PEEK_GAP_MS = 700;
|
|
const lastStepRef = useRef(0);
|
|
// A step is pushed BEFORE the change lands. Nested commits (a handler whose
|
|
// work calls another committing one, e.g. RESET -> applyRecipe) belong to the
|
|
// outer step, or BACK would need one press per layer of one press.
|
|
const commitDepthRef = useRef(0);
|
|
const commit = (coalesce: boolean, mutate: () => void) => {
|
|
if (commitDepthRef.current > 0) {
|
|
mutate();
|
|
return;
|
|
}
|
|
const now = Date.now();
|
|
if (!coalesce || now - lastStepRef.current > PEEK_GAP_MS) {
|
|
if (lookRef.current) pastRef.current.push(lookRef.current);
|
|
setUndoDepth(pastRef.current.length);
|
|
}
|
|
lastStepRef.current = coalesce ? now : 0;
|
|
commitDepthRef.current++;
|
|
try {
|
|
mutate();
|
|
} finally {
|
|
commitDepthRef.current--;
|
|
}
|
|
};
|
|
// The loaded look is step zero — nothing before it can be undone.
|
|
const clearHistory = () => {
|
|
pastRef.current = [];
|
|
lastStepRef.current = 0;
|
|
setUndoDepth(0);
|
|
};
|
|
// FX's MONOCHROME switch: not a look of its own — it swaps the base filter for
|
|
// the mono stock and swaps right back. Adjustments are never touched, so a knob
|
|
// turned while it is on survives the round trip.
|
|
const monoOn = selectedRecipe?.baseFilter === 'monochrome';
|
|
const monoPrevRef = useRef<Recipe | null>(null);
|
|
|
|
const handleToggleMono = () => {
|
|
commit(true, () => {
|
|
if (selectedRecipe?.baseFilter === 'monochrome') {
|
|
const back = monoPrevRef.current ?? filmSimToRecipe(FILM_SIMS.find((f) => f.id === 'sim-provia')!);
|
|
monoPrevRef.current = null;
|
|
setSelectedRecipe(back);
|
|
} else {
|
|
monoPrevRef.current = selectedRecipe;
|
|
setSelectedRecipe({
|
|
...(selectedRecipe ?? filmSimToRecipe(FILM_SIMS[0])),
|
|
id: 'mono',
|
|
name: 'MONOCHROME',
|
|
baseFilter: 'monochrome',
|
|
});
|
|
}
|
|
});
|
|
};
|
|
const applyLook = (l: Look) => {
|
|
setSelectedRecipe(l.recipe);
|
|
setAdjustments(l.adjustments);
|
|
setSelectedFrame(l.frame);
|
|
setCropRatio(l.cropRatio);
|
|
setCropApplied(l.cropApplied);
|
|
setCropRect(l.cropRect);
|
|
setPhotoRotation(l.photoRotation);
|
|
setPhotoStraighten(l.photoStraighten);
|
|
setWallLandscape(l.wallLandscape);
|
|
setUseGeotag(l.useGeotag);
|
|
setWmRotation(l.wmRotation);
|
|
setCustomWm(l.customWm);
|
|
setCustomWmApplied(l.customWmApplied);
|
|
setGpsWm(l.gpsWm);
|
|
};
|
|
const handleUndo = () => {
|
|
if (!pro) {
|
|
promptProFeature('Undo');
|
|
return;
|
|
}
|
|
const prev = pastRef.current.pop();
|
|
if (!prev) return;
|
|
setPeekLook(null);
|
|
lastStepRef.current = 0;
|
|
setUndoDepth(pastRef.current.length);
|
|
applyLook(prev);
|
|
};
|
|
const handlePeek = (on: boolean) => {
|
|
if (!on) {
|
|
setPeekLook(null);
|
|
return;
|
|
}
|
|
const before = pastRef.current[pastRef.current.length - 1];
|
|
setPeekLook(before ?? null);
|
|
};
|
|
// PEEK follows the finger on the picture (the chip is gone). Armed only when
|
|
// there is a step to compare against, so a long press to focus cannot turn
|
|
// into a paywall in a LITE build.
|
|
const peekArmed = pro && undoDepth > 0;
|
|
// The live look, mirrored every render so a handler can snapshot what was on
|
|
// screen just before it changes it.
|
|
const liveLook = lookNow();
|
|
lookRef.current = liveLook;
|
|
// The look the panel and the picture SHOW. While the finger is down on PEEK
|
|
// (or on the picture) every number and every tab travels back a step, so the
|
|
// before/after is readable, not just visible. The real values stay untouched:
|
|
// writes, RECIPE and the on-image drag baseline all read the live ones.
|
|
const shown = peekLook ?? liveLook;
|
|
const shownAdjustments = shown.adjustments;
|
|
const peeking = peekLook !== null;
|
|
// What the preview draws: the shown recipe's own chrome (base filter, name)
|
|
// rebuilt over the shown values, so a peek that steps over a PRESETS/RECIPES
|
|
// change brings that look's filter back with it. A camera start carries no
|
|
// recipe, so that stays the synthetic one.
|
|
const previewRecipe: Recipe = {
|
|
id: 'temp',
|
|
name: 'Custom',
|
|
baseFilter: 'none',
|
|
...(shown.recipe ?? {}),
|
|
adjustments: shown.adjustments,
|
|
frameId: shown.frame,
|
|
useGeotag: shown.useGeotag,
|
|
};
|
|
|
|
const handleUpdateAdjustments = (updates: Partial<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 };
|
|
// One drag = one step (see commit's coalescing): the step holds the values
|
|
// the drag started from, so BACK and PEEK land on the drag's own start.
|
|
// ponytail: a >700ms stall in the middle of one drag splits it in two.
|
|
commit(true, () => {
|
|
setAdjustments(next);
|
|
if (selectedRecipe) {
|
|
setSelectedRecipe({
|
|
...selectedRecipe,
|
|
adjustments: next,
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
// One band triple, as stored. A band back at [0,0,0] is DELETED, and the
|
|
// field goes back to `undefined` (not {}) when the last one leaves: the
|
|
// shallow differs() behind resetDirty then reads a clean mixer as clean.
|
|
const setBandKnob = (which: 0 | 1 | 2, v: number) => {
|
|
const band = adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
|
|
const next: HslBand = [band[0], band[1], band[2]];
|
|
next[which] = Math.round(v);
|
|
const bands = { ...adjustments.hslBands };
|
|
if (next[0] === 0 && next[1] === 0 && next[2] === 0) delete bands[hslBand];
|
|
else bands[hslBand] = next;
|
|
handleUpdateAdjustments({ hslBands: Object.keys(bands).length ? bands : undefined });
|
|
};
|
|
|
|
// The mixer's three whole-image knobs: one door for all three, so the ruler
|
|
// and the field it writes are the same value wherever they were opened.
|
|
const setHslGlobal = (which: 0 | 1 | 2, v: number) => {
|
|
const key = which === 0 ? 'hslHue' : which === 1 ? 'hslSat' : 'hslLum';
|
|
const clamped = Math.max(-10, Math.min(10, Math.round(v)));
|
|
handleUpdateAdjustments({ [key]: clamped } as Partial<ColorAdjustments>);
|
|
};
|
|
|
|
// The eyedropper's report: print the colour it read, point the ruler at that
|
|
// colour's band, hang the panel on the spot it came from, and put the tool
|
|
// away — one pick, one colour.
|
|
const pickColor = (
|
|
rgb: { r: number; g: number; b: number },
|
|
at: { fx: number; fy: number }
|
|
) => {
|
|
setHslSample(rgb);
|
|
setHslBand(nearestHslBand(rgbToHsl(rgb.r, rgb.g, rgb.b).h));
|
|
setHslPickedAt(at);
|
|
setPicking(false);
|
|
};
|
|
|
|
const handleUpdateFrame = (frameId: FrameId) => commit(false, () => applyFrame(frameId));
|
|
const applyFrame = (frameId: FrameId) => {
|
|
setSelectedFrame(frameId);
|
|
// CROP is a plain-frame tool (polaroid/wall crop through their own window).
|
|
// Keep the crop out of a frame that ignores it, or the ratio survives in the
|
|
// strip and lights the CROP chip on a frame the export never crops.
|
|
if (frameId !== 'none' && frameId !== 'classic-white' && frameId !== 'cinematic') {
|
|
setCropRatio('none');
|
|
setCropApplied(null);
|
|
setCropRect(DEFAULT_CROP_RECT);
|
|
}
|
|
if (selectedRecipe) {
|
|
setSelectedRecipe({
|
|
...selectedRecipe,
|
|
frameId,
|
|
});
|
|
}
|
|
};
|
|
|
|
// SETTINGS -> GPS. ON asks for the permission once and resolves a fix
|
|
// now, so the coordinates are already there when the shutter fires.
|
|
const handleToggleGps = async (enabled: boolean) => {
|
|
if (!enabled) {
|
|
setGpsEnabled(false);
|
|
// Only the device fix goes: a picked photo keeps its own EXIF location.
|
|
setGpsInfo((g) => (g && g.source === 'device' ? null : g));
|
|
return;
|
|
}
|
|
if (!(await requestLocationPermissions())) {
|
|
Alert.alert('Permission Denied', 'Enable location services to use GPS.');
|
|
return;
|
|
}
|
|
setGpsEnabled(true);
|
|
loadGPS();
|
|
};
|
|
|
|
const handleToggleGeotag = async (enabled: boolean) => {
|
|
if (enabled && !pro) {
|
|
promptProFeature('The GPS watermark');
|
|
return;
|
|
}
|
|
commit(false, () => {
|
|
setUseGeotag(enabled);
|
|
if (selectedRecipe) {
|
|
setSelectedRecipe({
|
|
...selectedRecipe,
|
|
useGeotag: enabled,
|
|
});
|
|
}
|
|
});
|
|
if (enabled && !gpsInfo) {
|
|
const locationGranted = await requestLocationPermissions();
|
|
if (locationGranted) {
|
|
setGpsEnabled(true);
|
|
loadGPS();
|
|
} else {
|
|
Alert.alert('Permission Denied', 'Enable location services to use GPS Watermark.');
|
|
setUseGeotag(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleAspectRatio = (ratio: AspectRatio) => {
|
|
setAspectRatio(ratio);
|
|
};
|
|
|
|
const handleRawEnabled = (enabled: boolean) => {
|
|
rawFailRef.current = false; // allow one recovery alert per enable
|
|
setRawEnabled(enabled);
|
|
if (enabled) {
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
}
|
|
};
|
|
|
|
const handleStartupMode = (mode: StartupMode) => {
|
|
setStartupMode(mode);
|
|
AsyncStorage.setItem(STARTUP_MODE_KEY, mode).catch(() => {});
|
|
};
|
|
|
|
// Persist device/camera settings on every change — not just on background —
|
|
// so they survive a kill/relaunch even under 'default' startup, which only
|
|
// resets the look (mode/ratio/recipe/geotag), not these.
|
|
useEffect(() => {
|
|
if (!settingsHydratedRef.current) return;
|
|
AsyncStorage.setItem(
|
|
CAMERA_SETTINGS_KEY,
|
|
JSON.stringify({ shutterSound, flashMode, meterMode, rawEnabled, gpsEnabled, touchToShutter })
|
|
).catch(() => {});
|
|
}, [shutterSound, flashMode, meterMode, rawEnabled, gpsEnabled, touchToShutter]);
|
|
|
|
// Latest-rawEnabled ref so the stable session-error callback below can read
|
|
// the live toggle without re-subscribing the Camera session on every render.
|
|
const rawEnabledRef = useRef(rawEnabled);
|
|
rawEnabledRef.current = rawEnabled;
|
|
// Attaching the DNG output while RAW is on adds a second ImageCapture use
|
|
// case — CameraX historically refuses two per camera. If the session errors
|
|
// in that state, drop RAW (removing the output reconfigures back to the
|
|
// single working JPEG session) instead of leaving a dead camera.
|
|
const handleCameraSessionError = useCallback((error: Error) => {
|
|
console.error('Camera session error:', error);
|
|
if (rawEnabledRef.current && !rawFailRef.current) {
|
|
rawFailRef.current = true;
|
|
setRawEnabled(false);
|
|
Alert.alert(
|
|
'RAW Unavailable',
|
|
'DNG capture could not start on this device, so RAW was turned off. JPEG capture keeps working.'
|
|
);
|
|
}
|
|
}, []);
|
|
|
|
// Save the freshly captured DNG into an album literally named "RAW" (create
|
|
// the album on first use). Failure never blocks the JPEG pipeline.
|
|
const saveRawToAlbum = async (uri: string): Promise<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 { exportRecipeXml } = await import('./src/utils/recipeShare');
|
|
const xml = exportRecipeXml({
|
|
id: 'share',
|
|
name,
|
|
baseFilter: activeRecipe.baseFilter,
|
|
adjustments,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
});
|
|
const file = `${FileSystem.cacheDirectory}${name.replace(/[^\w.-]+/g, '_') || 'recipe'}.rcx.xml`;
|
|
await FileSystem.writeAsStringAsync(file, xml);
|
|
if (!(await Sharing.isAvailableAsync())) {
|
|
Alert.alert('Recipe Exported', `Saved as ${file}`);
|
|
return;
|
|
}
|
|
await Sharing.shareAsync(file, { mimeType: 'application/xml', dialogTitle: `Share recipe "${name}"` });
|
|
} catch (e) {
|
|
Alert.alert('Export failed', e instanceof Error ? e.message : String(e));
|
|
}
|
|
};
|
|
|
|
// PRESETS -> IMPORT / SCAN QR: a `.recipe` file that arrived from elsewhere —
|
|
// picked off storage or fetched from a scanned link — becomes a normal custom
|
|
// recipe, and the look on screen switches to it.
|
|
const storeImportedRecipe = async (xml: string) => {
|
|
const { importRecipeXml } = await import('./src/utils/recipeShare');
|
|
const created = await saveCustomRecipe(importRecipeXml(xml));
|
|
setRecipes(await getAllRecipes());
|
|
applyRecipe(created);
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
Alert.alert('Recipe Imported', `"${created.name}" has been added.`);
|
|
};
|
|
|
|
// 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;
|
|
await storeImportedRecipe(await FileSystem.readAsStringAsync(picked.assets[0].uri));
|
|
} catch (e) {
|
|
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
|
|
}
|
|
};
|
|
|
|
// PRESETS -> SCAN QR: the landing page's recipe card carries a link to a
|
|
// photo's own `.recipe` file, so a scan is the same import with the file
|
|
// fetched instead of picked. The code holds a URL, never the recipe, so
|
|
// anything that is not the preset route — a shop link, a wifi code — is
|
|
// refused with the reason. The sheet closes either way; a failure is a
|
|
// dialog, not a scanner left running.
|
|
const handleScanRecipe = async (value: string) => {
|
|
setScanVisible(false);
|
|
if (liteSaveBlocked) {
|
|
promptRecipeLimit();
|
|
return;
|
|
}
|
|
try {
|
|
const link = /^https?:\/\/\S+\/api\/photos\/\d+\/preset\.recipe$/.exec(value.trim());
|
|
if (!link) throw new Error('That code is not a RecipesCam recipe link.');
|
|
const res = await fetch(link[0]);
|
|
if (!res.ok) throw new Error(`The recipe link answered ${res.status}.`);
|
|
await storeImportedRecipe(await res.text());
|
|
} 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) => {
|
|
const on = !favoriteIds.includes(id);
|
|
// LITE gets one favorite; PRO is what buys a list. Un-starring is never
|
|
// gated, so the way back under the limit is always open.
|
|
if (on && !pro && favoriteIds.length >= LITE_FAVORITE_LIMIT) {
|
|
promptProFeature(`Favoriting more than ${LITE_FAVORITE_LIMIT} recipe`);
|
|
return;
|
|
}
|
|
setFavoriteIds((ids) => (on ? [...ids, id] : ids.filter((x) => x !== id)));
|
|
setFavoriteId(id, on).catch((e) => console.warn('Failed to save favorite:', e));
|
|
};
|
|
|
|
// FRAME-tab AUTO: the photo is levelled from its OWN pixels, not from the
|
|
// phone. A library still carries no record of how the camera was held when
|
|
// it was shot (EXIF Orientation only knows 0/90/180/270), so the sensor had
|
|
// nothing to offer; the dominant line in the picture is the only horizon
|
|
// there is. A one-shot: it replaces whatever the slider or a previous AUTO
|
|
// left, and does nothing at all when the picture has no line worth trusting.
|
|
const handleAutoStraighten = useCallback(() => {
|
|
const tilt = viewfinderRef.current?.detectHorizon?.() ?? null;
|
|
if (tilt == null) return;
|
|
commit(false, () => setPhotoStraighten(-tilt));
|
|
// commit only reads refs, so the empty dep list stays honest.
|
|
}, []);
|
|
|
|
// OPEN IMAGE: the in-app picker (LibraryPickerModal) does the choosing — a
|
|
// grid, then the photo full screen to pinch/double-tap zoom and swipe through,
|
|
// then USE or CANCEL. The system picker it replaced returned the still on the
|
|
// first tap (no preview) and, on Android 13+, as a redacted cache copy with
|
|
// assetId=null — the grid uri keeps the MediaStore asset id, so with
|
|
// ACCESS_MEDIA_LOCATION granted the file is not redacted and tier-1 EXIF /
|
|
// tier-2 MediaStore both still work.
|
|
const handlePickImage = () => {
|
|
if (isProcessing) return;
|
|
setLibraryPickerVisible(true);
|
|
};
|
|
|
|
const handleLibraryPicked = async (asset: PickedPhotoAsset) => {
|
|
setLibraryPickerVisible(false);
|
|
if (!asset.uri) return;
|
|
setLibraryImageUri(asset.uri);
|
|
// A new photo starts level: the previous photo's turn and fine
|
|
// straighten must not carry over.
|
|
setPhotoRotation(0);
|
|
setPhotoStraighten(0);
|
|
setMode('library');
|
|
// Picking from the camera is the other half of that switch (the camera
|
|
// button is the way back), so the camera's edits must not land on the
|
|
// photo: the picked still starts at the recipe's own values too.
|
|
if (mode !== 'library') resetEditsToRecipe();
|
|
// Clear old GPS — will be replaced if the photo (or device) has location data
|
|
setGpsInfo(null);
|
|
|
|
// Multi-tier GPS resolution: EXIF → MediaLibrary. Never the device's own
|
|
// location — that is where the phone is now, not where the photo was
|
|
// taken, and stamping it just printed the wrong place name on the mark.
|
|
const { resolvePhotoGPS } = await import('./src/utils/photoGps');
|
|
const { gps, permissionDenied } = await resolvePhotoGPS(asset, { useGeotag: gpsEnabled });
|
|
setGpsInfo(gps);
|
|
if (permissionDenied) {
|
|
console.warn('Media library read permission denied — photo GPS unavailable.');
|
|
}
|
|
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
};
|
|
|
|
// System share intake. A shared photo arrives as the launch intent's
|
|
// EXTRA_STREAM, which only the Kotlin module can read (takeSharedImage): it
|
|
// copies the stream into the cache and this feeds it to the library pipeline
|
|
// as an ordinary photo — same path as a pick, GPS and all.
|
|
const sharedPickedRef = useRef(handleLibraryPicked);
|
|
sharedPickedRef.current = handleLibraryPicked;
|
|
useEffect(() => {
|
|
let stale = false;
|
|
let busy = false;
|
|
const importShared = async () => {
|
|
if (stale || busy) return;
|
|
busy = true;
|
|
try {
|
|
await sessionInitRef.current;
|
|
if (stale) return;
|
|
const shared = await RecipescamExport.takeSharedImage();
|
|
if (stale || !shared?.path) return;
|
|
sharedPickedRef.current({ uri: `file://${shared.path}`, assetId: shared.assetId || null });
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
};
|
|
importShared();
|
|
// The other half is a share landing on an app that is already alive: no
|
|
// mount pass sees that intent, only this background -> active transition.
|
|
// The native read clears the extra, so later resumes import nothing.
|
|
const sub = AppState.addEventListener('change', (nextAppState) => {
|
|
if (nextAppState === 'active') importShared();
|
|
});
|
|
return () => {
|
|
stale = true;
|
|
sub.remove();
|
|
};
|
|
}, []);
|
|
|
|
const activeRecipe: Recipe =
|
|
selectedRecipe || {
|
|
id: 'temp',
|
|
name: 'Custom',
|
|
baseFilter: 'none',
|
|
adjustments,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
};
|
|
|
|
// Thumbnail/preview target for the camera-mode right button: newest photo.
|
|
const lastPhotoUri = photoHistory.length > 0 ? photoHistory[photoHistory.length - 1].uri : null;
|
|
|
|
// The PRO feature the current look is using, or null while it is Lite-clean.
|
|
// Only the EXPORT consults it: the preview shows every look for free, so the
|
|
// answer is computed where the file is about to be written, not on the chips.
|
|
// PRO unlocks every look, so a paying user has nothing left in use: only a
|
|
// Lite session can trip them: a printed frame it previewed, or a GPS stamp
|
|
// that arrived with a restored session.
|
|
const proLook = pro ? null : proLookInUse({ frame: selectedFrame, gpsWm: useGeotag, hdf: adjustments.hdf ?? 0 });
|
|
// The native Kotlin exporter has no text-watermark draw, so a Lite export
|
|
// (which always carries the mark) must take the Skia engine.
|
|
const nativeExport = NATIVE_EXPORT && pro;
|
|
// Shutter/export with a PRO look: nothing is rendered — name the feature so
|
|
// the user knows which PRO build would render it.
|
|
const promptPro = (feature: string) => {
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
|
Alert.alert(
|
|
'PRO LOOK',
|
|
`${feature} can be previewed and edited, but exporting it needs PRO.`,
|
|
[
|
|
{ text: 'NOT NOW', style: 'cancel' },
|
|
{ text: 'UNLOCK PRO', onPress: () => setShowPro(true) },
|
|
]
|
|
);
|
|
};
|
|
// Saving, starring, spawning: the action IS the feature, so there is no look
|
|
// to preview first — a LITE tap names it and nothing else happens.
|
|
const promptProFeature = (feature: string) => {
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
|
Alert.alert('PRO FEATURE', `${feature} needs PRO.`, [
|
|
{ text: 'NOT NOW', style: 'cancel' },
|
|
{ text: 'UNLOCK PRO', onPress: () => setShowPro(true) },
|
|
]);
|
|
};
|
|
|
|
// Watermark handed to the export engines (Skia + native): the ACCEPTED mark
|
|
// (not the live draft), null while disabled or empty, else trimmed text +
|
|
// position. Trimming here keeps the stamped text identical to what the
|
|
// preview drew.
|
|
const wmForExport = (): {
|
|
text: string;
|
|
x: number;
|
|
y: number;
|
|
color?: string;
|
|
size?: number;
|
|
font?: string | null;
|
|
} | null =>
|
|
// The user's own mark is free in both tiers. LITE only narrows the FACE it
|
|
// may use (one basic font), not the mark itself.
|
|
customWmApplied.enabled && customWmApplied.text.trim().length > 0
|
|
? {
|
|
text: customWmApplied.text.trim(),
|
|
x: customWmApplied.x,
|
|
y: customWmApplied.y,
|
|
color: customWmApplied.color,
|
|
size: customWmApplied.size,
|
|
font: pro ? customWmApplied.font : null,
|
|
}
|
|
: null;
|
|
|
|
// The free build's own mark, burned in on top of the custom one.
|
|
const liteMark = pro ? null : LITE_MARK;
|
|
|
|
// `burst` marks one frame of a held-shutter burst: the loop already gave the
|
|
// single haptic and the click would machine-gun, so both are muted, and RAW
|
|
// is skipped (a DNG per frame doubles the exposures the burst has to fit).
|
|
const handleCapture = async (opts?: { burst?: boolean }) => {
|
|
if (isProcessing) return;
|
|
|
|
// A PRO look can be composed but not rendered: stop before the shutter, so
|
|
// no capture, no queue entry and no cache file is produced.
|
|
if (proLook) {
|
|
promptPro(proLook);
|
|
return;
|
|
}
|
|
|
|
// Camera mode (Phase 4, option A): real capture through vision-camera.
|
|
if (mode === 'camera') {
|
|
if (!cameraPermissionGranted) {
|
|
Alert.alert('Permission Needed', 'Grant camera permission to take photos.');
|
|
return;
|
|
}
|
|
if (!cameraReadyRef.current) {
|
|
pendingShotRef.current = true;
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
return;
|
|
}
|
|
pendingShotRef.current = false;
|
|
if (!photoOutput) {
|
|
Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.');
|
|
return;
|
|
}
|
|
// One capture in flight at a time — the shutter button, a viewfinder tap
|
|
// and a burst frame all land here.
|
|
if (captureBusyRef.current) return;
|
|
captureBusyRef.current = true;
|
|
if (!opts?.burst) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
try {
|
|
// A photo taken now is at the device's current spot — resolve device GPS
|
|
// fresh instead of reusing GPS left over from a previously loaded photo.
|
|
let captureGps = gpsInfo && gpsInfo.source === 'device' ? gpsInfo : null;
|
|
// Kick the fix off here but do NOT await it before the shutter: this is
|
|
// a real GPS read plus a network reverse-geocode, and the sensor sat
|
|
// idle for as long as it took. Resolved below, before the export queue.
|
|
// Only the GPS MARK needs a fresh fix on the capture path: without it
|
|
// the shutter must not wait for the receiver (two seconds of a
|
|
// stamp-less photo is worse than none). EXIF still gets whatever fix
|
|
// the launch prewarm already resolved.
|
|
const gpsPending = gpsEnabled && useGeotag && !captureGps ? getCurrentGPS() : null;
|
|
|
|
const capturedAt = Date.now(); // EXIF DateTimeOriginal / file name
|
|
// 0.5x is a native Camera2 session on the hidden ultra-wide lens, not a
|
|
// CameraX zoom ratio: no CameraX session is bound while it is live, so
|
|
// the still has to come from the native module — and Camera2 fires no
|
|
// shutter sound of its own. The LED is a CameraX capture setting, so the
|
|
// native module re-applies the viewfinder's flashMode on its own still
|
|
// request, and drops it back to 'off' on a lens with no flash unit.
|
|
const ultraWideShot = viewfinderRef.current?.isUltraWide?.() === true;
|
|
let filePath: string;
|
|
if (ultraWideShot) {
|
|
filePath = await UltraWide.captureAsync(flashMode);
|
|
} else {
|
|
filePath = (
|
|
await photoOutput.capturePhotoToFile(
|
|
{
|
|
enableShutterSound: shutterSound === 'on' && !opts?.burst,
|
|
// Capture-time LED flash. Forced back to 'off' on a lens with no
|
|
// flash unit: the spec throws for anything else.
|
|
flashMode: flashAvailable ? flashMode : 'off',
|
|
},
|
|
// The Nitro spec takes the callback bag as a required second
|
|
// argument (every field in it optional) — one argument throws.
|
|
{},
|
|
)
|
|
).filePath;
|
|
}
|
|
const sourceUri = toUri(filePath);
|
|
|
|
// RAW DNG sidecar: fire the second exposure immediately after the JPEG
|
|
// so the two shots stay a few tens of ms apart, then file it into the
|
|
// "RAW" album without blocking the JPEG export below.
|
|
if (rawEnabled && rawPhotoOutput && !opts?.burst && !ultraWideShot) {
|
|
try {
|
|
const rawFile = await rawPhotoOutput.capturePhotoToFile(
|
|
{
|
|
enableShutterSound: shutterSound === 'on',
|
|
// Same light as the JPEG it follows: the flash unit may still be
|
|
// recharging tens of ms later, in which case the DNG just lands
|
|
// darker than its JPEG — a silent raw, never a broken capture.
|
|
flashMode: flashAvailable ? flashMode : 'off',
|
|
},
|
|
{},
|
|
);
|
|
const rawUri = toUri(rawFile.filePath);
|
|
saveRawToAlbum(rawUri).then((ok) => {
|
|
if (!ok) {
|
|
Alert.alert('RAW Not Saved', 'The DNG was captured but could not be added to the RAW album.');
|
|
}
|
|
});
|
|
} catch (rawError) {
|
|
console.error('RAW capture failed:', rawError);
|
|
}
|
|
}
|
|
|
|
// Snapshot the look at shutter time, then render OFF the shutter path:
|
|
// processAndExportPhoto (~1s Skia render + gallery save) is enqueued on
|
|
// the FIFO background queue — the shutter returns in tens of ms and the
|
|
// next shot can be taken immediately; no full-screen RENDERING overlay.
|
|
// Finished photos land in history (and the gallery) when their turn
|
|
// completes; the thumbnail button shows each as it arrives.
|
|
// P5: the native path (NATIVE_EXPORT) renders through RecipesCamExport —
|
|
// same queue/pushPhoto/Haptics/Alert behavior, different renderer.
|
|
if (gpsPending) {
|
|
const deviceGps = await withDeadline(gpsPending, GPS_WAIT_MS);
|
|
if (deviceGps) {
|
|
captureGps = { ...deviceGps, source: 'device' as const };
|
|
setGpsInfo(captureGps);
|
|
}
|
|
}
|
|
|
|
const recipeName = activeRecipe.name;
|
|
// Read at shutter time, not render time — the queued render runs after
|
|
// the next shot and the gesture may have moved on.
|
|
const frameZoom = viewfinderRef.current?.getFrameWindowZoom?.() ?? null;
|
|
const render = async () => {
|
|
const recipeArgs = {
|
|
...activeRecipe,
|
|
adjustments: renderAdj,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
};
|
|
const options = {
|
|
// Live capture: gallery name RC_<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(),
|
|
liteMark,
|
|
watermarkRotation: wmRotation,
|
|
gpsWatermark: gpsWm,
|
|
wallframeLandscape: wallLandscape,
|
|
// FRAME-tab pinch/drag inside a polaroid card window or wall-frame
|
|
// opening: save the same crop the preview showed (null = 1x).
|
|
frameWindowZoom: frameZoom,
|
|
};
|
|
const paint = await loadRenderer(nativeExport);
|
|
return paint(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options);
|
|
};
|
|
enqueueExport(async () => {
|
|
try {
|
|
const result = await render();
|
|
if (result) {
|
|
// No auto preview after a capture — the thumbnail button opens
|
|
// the swipe viewer. Saved to session history either way.
|
|
pushPhoto({
|
|
uri: result.uri,
|
|
recipeName,
|
|
savedToLibrary: result.savedToLibrary,
|
|
assetId: result.assetId,
|
|
});
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
} else {
|
|
Alert.alert('Failed', 'Error occurred while processing the captured photo.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Camera export failed:', error);
|
|
Alert.alert('Failed', 'Error occurred while processing the captured photo.');
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Camera capture failed:', error);
|
|
Alert.alert('Capture Failed', 'Could not take the photo. See logs for details.');
|
|
} finally {
|
|
captureBusyRef.current = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Library mode capture/export: processes selected image with recipe, overlays, text
|
|
if (!libraryImageUri || !selectedRecipe) {
|
|
Alert.alert('Error', 'Please select an image from the library first.');
|
|
return;
|
|
}
|
|
|
|
setIsProcessing(true);
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
|
|
// Plain library frames export the photo at its own aspect (the ratio band is
|
|
// a camera-composition tool); polaroid/wall keep their framed ratio crop.
|
|
const libraryPlainFrame =
|
|
selectedFrame === 'none' || selectedFrame === 'classic-white' || selectedFrame === 'cinematic';
|
|
const libraryAspect = aspectRatio === 'full' || libraryPlainFrame ? undefined : aspectRatio;
|
|
// FRAME-tab CROP: only the plain frames (polaroid/wall crop through their
|
|
// own window). The native exporter knows nothing about it, so a cropped
|
|
// export always takes the Skia path.
|
|
// The fixed ratios take their rectangle from the viewfinder: the band plus
|
|
// whatever pinch/drag the user applied inside it. cropWH stays as the
|
|
// fallback for the (impossible in practice) case of an unloaded photo.
|
|
const ratioCrop =
|
|
cropRatio !== 'none' && cropRatio !== 'free'
|
|
? viewfinderRef.current?.getCropRatioRect?.() ?? null
|
|
: null;
|
|
const cropOptions: { cropWH?: number; cropRect?: CropRect } =
|
|
!libraryPlainFrame || cropRatio === 'none'
|
|
? {}
|
|
: cropRatio === 'free'
|
|
? { cropRect }
|
|
: ratioCrop
|
|
? { cropRect: ratioCrop }
|
|
: { cropWH: CROP_W_H[cropRatio] };
|
|
|
|
const paint = await loadRenderer(nativeExport && !cropOptions.cropRect);
|
|
const result = nativeExport && !cropOptions.cropRect
|
|
? await paint(
|
|
libraryImageUri,
|
|
{
|
|
...selectedRecipe,
|
|
adjustments,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
},
|
|
selectedFrame,
|
|
useGeotag,
|
|
gpsInfo,
|
|
// Library export keeps the on-screen composition: plain frames export
|
|
// the photo as seen (own aspect), polaroid/wall keep their ratio crop.
|
|
{
|
|
aspect: libraryAspect,
|
|
// Library export: RC_<ts>_exported.jpg, and the loaded photo's
|
|
// EXIF (camera, dates, GPS) is carried into the file.
|
|
exported: true,
|
|
watermark: wmForExport(),
|
|
liteMark,
|
|
watermarkRotation: wmRotation,
|
|
gpsWatermark: gpsWm,
|
|
wallframeLandscape: wallLandscape,
|
|
}
|
|
)
|
|
: await paint(
|
|
libraryImageUri,
|
|
{
|
|
...selectedRecipe,
|
|
adjustments,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
},
|
|
selectedFrame,
|
|
useGeotag,
|
|
gpsInfo,
|
|
// Library export keeps the on-screen composition: plain frames export
|
|
// the photo as seen (own aspect), polaroid/wall keep their ratio crop.
|
|
{
|
|
aspect: libraryAspect,
|
|
// Library export: RC_<ts>_exported.jpg, and the loaded photo's
|
|
// EXIF (camera, dates, GPS) is carried into the file.
|
|
exported: true,
|
|
watermark: wmForExport(),
|
|
liteMark,
|
|
watermarkRotation: wmRotation,
|
|
gpsWatermark: gpsWm,
|
|
wallframeLandscape: wallLandscape,
|
|
// FRAME-tab CROP: the exact keep-rectangle the preview showed.
|
|
...cropOptions,
|
|
// Pinch/drag inside a polaroid card window or wall-frame opening:
|
|
// save the same crop the preview showed (null = centered 1x).
|
|
frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null,
|
|
photoRotation,
|
|
photoStraighten,
|
|
}
|
|
);
|
|
|
|
setIsProcessing(false);
|
|
|
|
if (result) {
|
|
pushPhoto({
|
|
uri: result.uri,
|
|
recipeName: activeRecipe.name,
|
|
savedToLibrary: result.savedToLibrary,
|
|
assetId: result.assetId,
|
|
});
|
|
// Library has no thumbnail button (right button switches back to camera),
|
|
// so the export result auto-opens the viewer on the newest photo.
|
|
setViewerIndex(photoHistory.length);
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
} else {
|
|
Alert.alert('Failed', 'Error occurred while processing image.');
|
|
}
|
|
};
|
|
|
|
// Volume keys as a shutter release. `handleCapture` is rebuilt every render,
|
|
// so the listener is bound once and reaches the current one through a ref.
|
|
const captureFromKeyRef = useRef(handleCapture);
|
|
captureFromKeyRef.current = handleCapture;
|
|
// Session up -> replay a queued press. Stable identity: the Viewfinder's
|
|
// onStarted/onStopped callbacks are memoised around it.
|
|
const handleCameraReady = useCallback((ready: boolean) => {
|
|
cameraReadyRef.current = ready;
|
|
if (ready && pendingShotRef.current) {
|
|
pendingShotRef.current = false;
|
|
setTimeout(() => void captureFromKeyRef.current(), 0);
|
|
}
|
|
}, []);
|
|
useEffect(() => {
|
|
const sub = VolumeShutter.addListener('onShutter', () => {
|
|
void captureFromKeyRef.current();
|
|
});
|
|
return () => sub.remove();
|
|
}, []);
|
|
// Only the camera screen claims the keys; everywhere else (the library)
|
|
// they stay volume keys. The modals need no flag of their own: an RN
|
|
// Modal is a window of its own and holds the focus, so the activity never
|
|
// sees a key while one is up.
|
|
useEffect(() => {
|
|
VolumeShutter.setEnabled(mode === 'camera');
|
|
}, [mode]);
|
|
|
|
// Held-shutter burst: press and hold the shutter, frames keep coming until
|
|
// the finger lifts. Every frame is a normal capture (GPS resolve, RAW rules,
|
|
// background render enqueue), so a burst photo is indistinguishable from a
|
|
// tapped one apart from the muted haptic/sound. The `await` IS the pacing: a
|
|
// frame cannot start before the previous one is on disk, which is why no
|
|
// timer is needed (and why 'speed' buys nothing extra here).
|
|
// ponytail: BURST_MAX is a runaway guard for a lost press-out (responder
|
|
// terminated mid-hold); raise it if bursts longer than 30 frames are wanted.
|
|
const BURST_MAX = 30;
|
|
const burstRef = useRef(false);
|
|
const handleBurstStart = () => {
|
|
if (mode !== 'camera' || burstRef.current) return;
|
|
if (proLook) {
|
|
promptPro(proLook);
|
|
return;
|
|
}
|
|
burstRef.current = true;
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
void (async () => {
|
|
for (let i = 0; i < BURST_MAX && burstRef.current; i++) {
|
|
await handleCapture({ burst: true });
|
|
}
|
|
burstRef.current = false;
|
|
})();
|
|
};
|
|
const handleBurstStop = () => {
|
|
burstRef.current = false;
|
|
};
|
|
|
|
// Switching back to camera: drop GPS that belongs to a library photo, so the
|
|
// live view (and any capture) never stamps a stale location.
|
|
const handleSetMode = (next: 'camera' | 'library') => {
|
|
if (next === mode) return;
|
|
if (next === 'camera' && gpsInfo && gpsInfo.source !== 'device') {
|
|
setGpsInfo(null);
|
|
}
|
|
// The deck panels belong to one mode only (FRAME exists in library alone,
|
|
// and the WATERMARK panel is the watermark's edit session), so every switch
|
|
// closes them: no frame chip row over the live preview, and no watermark
|
|
// placement layer left eating the camera's tap-to-focus.
|
|
setOpenTab(null);
|
|
setOpenParamKey(null);
|
|
resetEditsToRecipe();
|
|
// Camera mode has no FRAME picker to undo a frame, so even a recipe that
|
|
// carries one must not follow the user onto the live camera (and into the
|
|
// capture, which burns the frame in).
|
|
if (next === 'camera') setSelectedFrame('none');
|
|
setMode(next);
|
|
};
|
|
|
|
// Append a finished export (camera capture or library export) to the session
|
|
// photo history. Returned entry id lets callers delete by id.
|
|
const pushPhoto = (p: Omit<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);
|
|
// The eyedropper belongs to the HSL row: leave the tab (or close the panel)
|
|
// and the layer goes with it, so an invisible pick layer cannot sit over
|
|
// the picture swallowing pan and zoom.
|
|
setPicking(false);
|
|
};
|
|
|
|
// 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={previewRecipe}
|
|
selectedFrame={shown.frame}
|
|
useGeotag={shown.useGeotag}
|
|
gpsInfo={gpsInfo}
|
|
libraryImageUri={libraryImageUri}
|
|
availableHeight={deckTop}
|
|
topInset={topInset}
|
|
photoRotation={shown.photoRotation}
|
|
photoStraighten={shown.photoStraighten}
|
|
cameraPermissionGranted={cameraPermissionGranted}
|
|
onRequestCameraPermission={requestCameraPermission}
|
|
// One lens, two camera libraries: while the QR sheet is up its own
|
|
// scanner owns the camera and this session has to let go.
|
|
paused={scanVisible}
|
|
photoOutput={photoOutput}
|
|
rawOutput={rawPhotoOutput}
|
|
rawEnabled={rawEnabled}
|
|
aspectRatio={aspectRatio}
|
|
cropRatio={shown.cropRatio}
|
|
cropApplied={shown.cropApplied}
|
|
cropRect={shown.cropRect}
|
|
onCropRectChange={(r) => commit(true, () => setCropRect(r))}
|
|
onSessionError={handleCameraSessionError}
|
|
onCameraReady={handleCameraReady}
|
|
onExposureChange={(ev) => handleUpdateAdjustments({ exposureCompensation: ev })}
|
|
meteringAdjustments={meteringActive ? meteringOverlay(shownAdjustments) : undefined}
|
|
imageAdjustTarget={imageAdjustTarget}
|
|
onComparePeek={peekArmed ? handlePeek : undefined}
|
|
// Touch-to-shutter: tap the live image -> focus locks -> capture.
|
|
onTouchShutter={touchToShutter ? handleCapture : undefined}
|
|
customWm={shown.customWm}
|
|
wmRotation={shown.wmRotation}
|
|
gpsWm={shown.gpsWm}
|
|
onGpsWmChange={(x, y, size) => commit(true, () => setGpsWm((g) => ({ ...g, x, y, size })))}
|
|
onWmSizeChange={(size) => commit(true, () => setCustomWm((p) => ({ ...p, size })))}
|
|
wallLandscape={shown.wallLandscape}
|
|
// The mark only captures image touches while its panel is open, so a
|
|
// watermark left ON cannot block tap-to-focus or pinch/zoom.
|
|
wmEditing={openTab === 'frame' && wmPanelOpen}
|
|
frameTabActive={openTab === 'frame'}
|
|
onWmPositionChange={(x, y) => commit(true, () => setCustomWm((p) => ({ ...p, x, y })))}
|
|
onWmEdit={() => {
|
|
// Double-tap on the mark re-opens its editor (and with it the
|
|
// placement layer), then focuses the input.
|
|
setOpenTab('frame');
|
|
setOpenParamKey(null);
|
|
setWmEditNonce((n) => n + 1);
|
|
}}
|
|
// HSL eyedropper: the viewer owns the pick layer and hangs the panel
|
|
// it is handed; the state behind both is the HSL row's, above.
|
|
picking={picking}
|
|
hslPanelAt={hslPickedAt}
|
|
onPickColor={pickColor}
|
|
hslPanel={
|
|
openTab === 'hsl' && hslSample && hslPickedAt ? (
|
|
<HslPickPanel
|
|
sample={hslSample}
|
|
bandLabel={HSL_BANDS.find((b) => b.id === hslBand)?.label ?? ''}
|
|
band={shownAdjustments.hslBands?.[hslBand] ?? [0, 0, 0]}
|
|
onKnob={setBandKnob}
|
|
onClose={() => setHslPickedAt(null)}
|
|
/>
|
|
) : undefined
|
|
}
|
|
/>
|
|
</View>
|
|
|
|
{/* Top overlay: mode label + recipe name (left), Save current (right) */}
|
|
<TopBar
|
|
mode={mode}
|
|
recipeName={peeking ? previewRecipe.name : activeRecipe.name}
|
|
onSaveRecipe={handleSaveRecipe}
|
|
onOverwriteRecipe={selectedRecipe?.isCustom ? handleOverwriteRecipe : undefined}
|
|
onEditRecipe={
|
|
selectedRecipe?.isCustom ? () => setEditingRecipe(selectedRecipe) : undefined
|
|
}
|
|
onExportShare={handleExportShare}
|
|
onUndo={handleUndo}
|
|
undoDepth={undoDepth}
|
|
// Capture-time LED flash: offered only on a lens that carries a flash
|
|
// unit, and only in camera mode (the library save never fires the
|
|
// camera). Tap cycles OFF -> AUTO -> ON -> OFF.
|
|
flashMode={mode === 'camera' && flashAvailable ? flashMode : undefined}
|
|
onCycleFlash={
|
|
mode === 'camera' && flashAvailable
|
|
? () => setFlashMode((m) => (m === 'off' ? 'auto' : m === 'auto' ? 'on' : 'off'))
|
|
: undefined
|
|
}
|
|
onHeight={setTopInset}
|
|
/>
|
|
|
|
{/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */}
|
|
<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={shown.frame}
|
|
cropRatio={shown.cropRatio}
|
|
onUpdateCropRatio={(r) => commit(false, () => setCropRatio(r))}
|
|
cropApplied={shown.cropApplied}
|
|
onApplyCrop={() => commit(false, () => setCropApplied(cropRatio))}
|
|
cropEnabled={
|
|
mode === 'library' &&
|
|
(selectedFrame === 'none' ||
|
|
selectedFrame === 'classic-white' ||
|
|
selectedFrame === 'cinematic')
|
|
}
|
|
photoRotation={shown.photoRotation}
|
|
photoStraighten={shown.photoStraighten}
|
|
onPickRotation={(d) => commit(false, () => setPhotoRotation(d))}
|
|
onStraighten={(d) => commit(true, () => setPhotoStraighten(d))}
|
|
onAutoStraighten={handleAutoStraighten}
|
|
wallLandscape={shown.wallLandscape}
|
|
onToggleWallLandscape={() => commit(false, () => setWallLandscape((v) => !v))}
|
|
useGeotag={shown.useGeotag}
|
|
pro={pro}
|
|
openParam={openParamKey}
|
|
onOpenParamChange={setOpenParamKey}
|
|
currentBaseFilter={selectedRecipe?.baseFilter}
|
|
monoOn={monoOn}
|
|
onToggleMono={handleToggleMono}
|
|
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}
|
|
onScanRecipe={() => setScanVisible(true)}
|
|
recipeLimitReached={liteSaveBlocked}
|
|
onRecipeLimit={promptRecipeLimit}
|
|
onDeleteRecipe={handleDeleteRecipe}
|
|
customWm={shown.customWm}
|
|
onUpdateCustomWm={handleUpdateCustomWm}
|
|
gpsWm={shown.gpsWm}
|
|
onUpdateGpsWm={(wm) => commit(true, () => setGpsWm(wm))}
|
|
onAcceptCustomWm={handleAcceptCustomWm}
|
|
onCancelCustomWm={handleCancelCustomWm}
|
|
wmEditNonce={wmEditNonce}
|
|
wmRotation={shown.wmRotation}
|
|
onRotateWm={() => commit(false, () => setWmRotation((r) => (r + 90) % 360))}
|
|
onWmPanelChange={setWmPanelOpen}
|
|
hslBand={hslBand}
|
|
onHslBand={setHslBand}
|
|
picking={picking}
|
|
onPicking={setPicking}
|
|
hslSample={hslSample}
|
|
onSetHslGlobal={setHslGlobal}
|
|
// A live preview has no pixel to read, so PICK is library-only.
|
|
canPick={mode === 'library'}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
<ToolRail activeTab={openTab} onToggle={handleToggleTab} />
|
|
|
|
{/* Library picker (grid -> zoomable preview -> USE / CANCEL) */}
|
|
<LibraryPickerModal
|
|
visible={libraryPickerVisible}
|
|
onCancel={() => setLibraryPickerVisible(false)}
|
|
onSelect={handleLibraryPicked}
|
|
/>
|
|
|
|
{/* QR sheet: reads a recipe link off another screen (the landing page's
|
|
card) and imports the file it points at. */}
|
|
<QrScanModal
|
|
visible={scanVisible}
|
|
onCancel={() => setScanVisible(false)}
|
|
onScanned={handleScanRecipe}
|
|
/>
|
|
|
|
<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}
|
|
onUpgrade={() => setShowPro(true)}
|
|
/>
|
|
|
|
{/* The paywall: the only place the tier is sold. */}
|
|
<ProModal visible={showPro} onClose={() => setShowPro(false)} />
|
|
|
|
{/* 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>
|
|
);
|
|
}
|