import React, { useEffect, useState } from 'react'; import { View, Text, TextInput, ScrollView, TouchableOpacity, Modal, KeyboardAvoidingView, Platform, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as Haptics from 'expo-haptics'; import { Recipe, ColorAdjustments, BaseFilter } from '../types'; import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from '../utils/defaultRecipes'; // "CREATE" quick-input form shown above the viewfinder. Numeric knobs are // plain textboxes (typed fast, one row each), categorical knobs are chip // dropdowns. Used to enter Fuji-style recipes (DR/Highlight/Shadow/Color/NR/ // Sharpness/Clarity/Grain/CC/CCB/WB+shift/ISO/EV) without touching sliders. const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); interface RecipeCreateModalProps { visible: boolean; // Id of the currently applied film sim (or bundled recipe whose look should // prefill the SIMULATION row); undefined → default to PROVIPES. currentBase?: BaseFilter; // The look live on screen. CREATE opens on THESE values (not on the sim's // standard ones) so a recipe is a snapshot of what the user is looking at; // every knob the form has no row for rides along on SAVE. current?: ColorAdjustments | null; // When set the form opens as an EDITOR: every row (name included) is // prefilled from this recipe instead of the sim's standard values, and // SAVE hands the edited copy back for the caller to store. initial?: Recipe | null; onClose: () => void; onSave: (recipe: Omit) => void; } // WB presets mirror the WB-tab chips but keep pure presets (no -R variants — // those are the separate Red/Blue offset textboxes below). const WB_PRESETS: { key: string; label: string; temperature: number; tint: number }[] = [ { key: 'auto', label: 'AUTO', temperature: 5500, tint: 0 }, { key: 'daylight', label: 'DAYLIGHT', temperature: 5500, tint: 0 }, { key: 'cloudy', label: 'CLOUDY', temperature: 6500, tint: 1 }, { key: 'shade', label: 'SHADE', temperature: 8000, tint: 2 }, { key: 'tungsten', label: 'TUNGSTEN', temperature: 3200, tint: 0 }, { key: 'fluor', label: 'FLUOR', temperature: 4000, tint: 3 }, ]; const WB_KEYS = WB_PRESETS.map((w) => w.key); const DR_OPTS = [ { v: 'auto', d: 'AUTO' }, { v: '100', d: 'DR100' }, { v: '200', d: 'DR200' }, { v: '400', d: 'DR400' }, ] as const; const CC_OPTS = [ { v: 'none', d: 'OFF' }, { v: 'weak', d: 'WEAK' }, { v: 'strong', d: 'STRONG' }, ] as const; const GRAIN_OPTS = [ { v: '0', d: 'OFF' }, { v: '3', d: 'WEAK' }, { v: '6', d: 'STRONG' }, ] as const; // ISO has no manual control path on this build (the vision-camera lib only // exposes exposure locking on iOS; Android CameraX keeps ISO automatic), so the // modal shows AUTO. If a native path ever appears, feed isoSupported=true and // the row below turns into a selectable chip list. const ISO_SUPPORTED = false; const ISO_STOPS = [200, 400, 800, 1600, 3200, 6400]; const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); const int = (s: string, fallback: number, lo: number, hi: number) => { const n = parseInt(s, 10); return Number.isFinite(n) ? clamp(n, lo, hi) : fallback; }; interface ChipProps { label: string; active: boolean; onPress: () => void; disabled?: boolean; } const Chip = ({ label, active, onPress, disabled }: ChipProps) => ( { if (!disabled) { haptic(); onPress(); } }} className={`mx-0.5 rounded-full border px-2.5 py-1 ${ disabled ? 'border-zinc-800 bg-black/30' : active ? 'border-amber-500/70 bg-amber-500/15' : 'border-zinc-700/60 bg-titan/80' }`} activeOpacity={0.7} > {label} ); const ChipRow = ({ children }: { children: React.ReactNode }) => ( {children} ); const LABEL_W = 132; interface NumRowProps { label: string; value: string; onChange: (s: string) => void; placeholder?: string; width?: number; } const NumRow = ({ label, value, onChange, placeholder, width = 58 }: NumRowProps) => ( {label} ); export default function RecipeCreateModal({ visible, currentBase, current, initial, onClose, onSave, }: RecipeCreateModalProps) { const insets = useSafeAreaInsets(); // Seed from what the user is LOOKING AT: the recipe being edited, else the live // adjustments, else the current sim's standard values. Seeding from the sim's // standard values alone (the old behaviour) meant the form showed — and SAVE // wrote — neutral numbers for knobs the user had already dialled in. const seedBase: BaseFilter = initial?.baseFilter ?? (currentBase && FILM_SIMS.some((s) => s.baseFilter === currentBase) ? currentBase : 'provia'); const seedAdj: ColorAdjustments = initial?.adjustments ?? current ?? FILM_SIMS.find((s) => s.baseFilter === seedBase)?.adjustments ?? DEFAULT_ADJUSTMENTS; // Every sim's standard values as form state — used to seed the modal and to // reload the form when the SIMULATION chip changes. const valuesOf = (b: BaseFilter, src?: ColorAdjustments) => { const s = src ?? FILM_SIMS.find((x) => x.baseFilter === b)?.adjustments ?? DEFAULT_ADJUSTMENTS; return { dr: String(s.dynamicRange ?? 'auto'), // Raw, not rounded to the three chips: a look sitting on grain 2 must not // become 3 just because the form was opened (and the chip row below shows // that value as its own chip when it is none of OFF/WEAK/STRONG). grain: String(s.grain ?? 0), cc: s.colorChrome ?? 'none', ccb: s.colorChromeBlue ?? 'none', // 'custom' = a WB the six chips do not name (a hand-set 6300K, say). It // keeps its own chip and its own value; the old AUTO fallback rewrote the // recipe's WB to 5500K on every save. wbKey: WB_KEYS.find((k) => { const p = WB_PRESETS.find((w) => w.key === k)!; return p.temperature === s.temperature && p.tint === s.tint; }) ?? 'custom', highlight: String(s.highlight), shadow: String(s.shadow), color: String(s.saturation), nr: String(s.denoise), sharp: String(s.sharpening), clarity: String(s.clarity), ev: String(s.exposureCompensation), }; }; const [sim, setSim] = useState(seedBase); const [name, setName] = useState(''); const [dr, setDr] = useState(seedAdj.dynamicRange == null ? 'auto' : String(seedAdj.dynamicRange)); const [grain, setGrain] = useState(String(seedAdj.grain ?? 0)); const [cc, setCc] = useState(seedAdj.colorChrome ?? 'none'); const [ccb, setCcb] = useState(seedAdj.colorChromeBlue ?? 'none'); const [wbKey, setWbKey] = useState( WB_KEYS.find((k) => { const p = WB_PRESETS.find((w) => w.key === k)!; return p.temperature === seedAdj.temperature && p.tint === seedAdj.tint; }) ?? 'custom' ); const [wbRed, setWbRed] = useState('0'); const [wbBlue, setWbBlue] = useState('0'); const [fHigh, setFHigh] = useState(String(seedAdj.highlight)); const [fShadow, setFShadow] = useState(String(seedAdj.shadow)); const [fColor, setFColor] = useState(String(seedAdj.saturation)); const [fNr, setFNr] = useState(String(seedAdj.denoise)); const [fSharp, setFSharp] = useState(String(seedAdj.sharpening)); const [fClarity, setFClarity] = useState(String(seedAdj.clarity)); const [fEv, setFEv] = useState(String(seedAdj.exposureCompensation)); const [iso, setIso] = useState(null); // Re-seed whenever the modal opens (or the applied look changes) so CREATE // quick-input always starts from the current simulation. useEffect(() => { if (!visible) return; // Editing a stored recipe wins over the live sim seed: the form must show // that recipe's own values, not the sim's standard ones. const base = initial?.baseFilter ?? seedBase; const v = valuesOf(base, seedAdj); setSim(base); setDr(v.dr); setGrain(v.grain); setCc(v.cc); setCcb(v.ccb); setWbKey(v.wbKey); setWbRed(String(seedAdj.wbRed ?? 0)); setWbBlue(String(seedAdj.wbBlue ?? 0)); setFHigh(v.highlight); setFShadow(v.shadow); setFColor(v.color); setFNr(v.nr); setFSharp(v.sharp); setFClarity(v.clarity); setFEv(v.ev); setName(initial?.name ?? ''); setIso(null); }, [visible, currentBase, initial]); // Switching the simulation reloads its standard values into the form — not // in the editor though: there the numbers on screen are the user's own, and // swapping the base look under them must not throw them away. const selectSim = (b: BaseFilter) => { setSim(b); if (initial) return; const v = valuesOf(b); setDr(v.dr); setGrain(v.grain); setCc(v.cc); setCcb(v.ccb); setWbKey(v.wbKey); setFHigh(v.highlight); setFShadow(v.shadow); setFColor(v.color); setFNr(v.nr); setFSharp(v.sharp); setFClarity(v.clarity); setFEv(v.ev); }; const handleSave = () => { const wb = WB_PRESETS.find((w) => w.key === wbKey); const simAdj = FILM_SIMS.find((s) => s.baseFilter === sim)?.adjustments ?? DEFAULT_ADJUSTMENTS; // The base is what the user was looking at (CREATE) or the recipe being // edited. Spreading the sim's neutral values here instead — the old // behaviour — silently zeroed every knob the form has no row for: exposure, // contrast, vibrance, vignetting, glow, the WB red/blue shift. That is what // made a saved recipe apply a different look from the one on screen. const adj: ColorAdjustments = { ...DEFAULT_ADJUSTMENTS, ...seedAdj, // A WB with no preset of its own keeps the value it was seeded with. temperature: wb ? wb.temperature : seedAdj.temperature, tint: wb ? wb.tint : seedAdj.tint, wbRed: int(wbRed, 0, -9, 9), wbBlue: int(wbBlue, 0, -9, 9), highlight: int(fHigh, simAdj.highlight, -10, 10), shadow: int(fShadow, simAdj.shadow, -10, 10), saturation: int(fColor, simAdj.saturation, -10, 10), denoise: int(fNr, simAdj.denoise, 0, 10), sharpening: int(fSharp, simAdj.sharpening, 0, 10), clarity: int(fClarity, simAdj.clarity, -10, 10), grain: int(grain, seedAdj.grain, 0, 10), colorChrome: cc as ColorAdjustments['colorChrome'], colorChromeBlue: ccb as ColorAdjustments['colorChromeBlue'], dynamicRange: (dr === 'auto' ? 'auto' : parseInt(dr, 10)) as ColorAdjustments['dynamicRange'], exposureCompensation: int(fEv, simAdj.exposureCompensation, -3, 3), }; onSave({ name: name.trim() || initial?.name || 'NEW RECIPE', baseFilter: sim, adjustments: adj, // An edit keeps the recipe's own frame and geotag choice; a fresh // CREATE keeps the old defaults. frameId: initial?.frameId ?? 'none', useGeotag: initial?.useGeotag ?? true, ...(initial?.cinema ? { cinema: initial.cinema } : {}), }); setName(''); onClose(); }; const fieldSets: { label: string; value: string; set: (s: string) => void; ph?: string }[] = [ { label: 'HIGHLIGHT', value: fHigh, set: setFHigh }, { label: 'SHADOW', value: fShadow, set: setFShadow }, { label: 'COLOR', value: fColor, set: setFColor }, { label: 'NOISE RED.', value: fNr, set: setFNr, ph: '0-10' }, { label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '0-10' }, { label: 'CLARITY', value: fClarity, set: setFClarity }, { label: 'EXPOSURE COMP.', value: fEv, set: setFEv, ph: 'EV' }, ]; return ( {initial ? 'EDIT RECIPE' : 'CREATE RECIPE'} ✕ {/* Name */} NAME {/* Simulation */} SIMULATION {FILM_SIMS.map((s) => ( selectSim(s.baseFilter)} /> ))} {/* Dynamic range */} DYNAMIC RANGE {DR_OPTS.map((o) => ( setDr(o.v)} /> ))} {/* Numeric textboxes — 2 per row */} {fieldSets.map((f) => ( ))} {/* Grain / chrome */} GRAIN EFFECT {GRAIN_OPTS.map((o) => ( setGrain(o.v)} /> ))} {!GRAIN_OPTS.some((o) => o.v === grain) && ( {}} /> )} COLOR CHROME EFFECT {CC_OPTS.map((o) => ( setCc(o.v)} /> ))} COLOR CHROME EFFECT BLUE {CC_OPTS.map((o) => ( setCcb(o.v)} /> ))} {/* White balance + R/B shift */} WHITE BALANCE {WB_PRESETS.map((w) => ( setWbKey(w.key)} /> ))} {wbKey === 'custom' && ( setWbKey('custom')} /> )} WB SHIFT RED BLUE {/* ISO */} ISO {ISO_SUPPORTED ? ( setIso(null)} /> {ISO_STOPS.map((s) => ( setIso(s)} /> ))} ) : ( {}} /> )} CANCEL {initial ? 'SAVE' : 'SAVE RECIPE'} ); }