import React, { useEffect, useMemo, useRef, useState } from 'react'; import { View, Text, TextInput, TouchableOpacity, ScrollView } from 'react-native'; import Slider from '@react-native-community/slider'; import * as Haptics from 'expo-haptics'; import { Trash2 } from 'lucide-react-native'; import { Skia } from '@shopify/react-native-skia'; import { Recipe, ColorAdjustments, CustomWatermark, FrameId, BaseFilter } from '../types'; import { FRAMES } from '../utils/frameUtils'; import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs'; import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes'; import RecipeCreateModal from './RecipeCreateModal'; // Compact bottom sheet above the tool rail: // bottom row = the tool's chips (presets / parameters), horizontally scrollable // rows above = one full-width slider per OPEN parameter, labeled with its name. // Tapping a parameter chip toggles its slider row; chips stay visible. // Darkroom-flavoured: amber = active/edited, red = destructive, mono labels. type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'watermark'; // Choice-group keys that open a strip above the chip row: color chrome groups // (wb tab) and the D.RANGE selector (iq tab). type GroupKey = 'cx' | 'cxb' | 'dr' | 'wmColor' | 'wmSize' | 'wmFont'; interface AdjustmentPanelProps { activeTab: TabId; recipes: Recipe[]; currentRecipeId: string; adjustments: ColorAdjustments; selectedFrame: FrameId; useGeotag: boolean; // RAW (DNG sidecar) / aspect-ratio / metering-mode now live in the gear // Settings sheet (gear between library picker and shutter) — no longer // tool-rail tabs. // The parameter whose slider row is open — lifted to App so it can also drive // drag-anywhere-on-the-image adjustment while the row is open. openParam: string | null; onOpenParamChange: (key: string | null) => void; // Base filter of the currently applied recipe — seeds the CREATE modal's // SIMULATION row so quick-input starts from the active look. currentBaseFilter?: BaseFilter; // CREATE quick-input saves a brand-new recipe (name + adjustments from the // modal) and applies it, unlike onSaveRecipe which snapshots the live state. onCreateRecipe: (recipe: Omit) => void; onSelectRecipe: (recipe: Recipe) => void; onUpdateAdjustments: (adjustments: Partial) => void; onUpdateFrame: (frameId: FrameId) => void; // FRAME tab: turn the inserted library photo 90deg (landscape <-> portrait) // so it fits the chosen frame better. photoRotation: 0 | 90; onToggleRotation: () => void; onToggleGeotag: (enabled: boolean) => void; onSaveRecipe: (name: string) => void; onDeleteRecipe: (id: string) => void; // WATERMARK tab state — lifted to App so the live Viewfinder overlay, this // input and the export options all read the same object. customWm: CustomWatermark; onUpdateCustomWm: (wm: CustomWatermark) => void; // ACCEPT: copy the live draft into the mark the export burns in. Edits stay a // draft (viewfinder preview only) until this is pressed. onAcceptCustomWm: () => void; // CANCEL: throw the un-accepted draft away — back to the mark that was last // ACCEPTED (or none at all) — and close the editor. The way out of a running // watermark edit that previously did not exist. onCancelCustomWm: () => void; // Bumped by a double-tap on the mark: put the caret back in the text input. wmEditNonce: number; } interface ChipDef { key: string; label: string; active: boolean; onPress: () => void; onPressDelete?: () => void; // custom presets carry an inline trash button trash?: boolean; disabled?: boolean; // informational label, not tappable amberValue?: boolean; // amber text driven by edited value, not selection } interface ParamDef { key: string; label: string; value: number; default: number; min: number; max: number; step: number; display: (v: number) => string; onChange: (v: number) => void; } const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); // WB preset → engine pair. AUTO and DAYLIGHT both land on the neutral // 5500K / 0 pair (the engine has no scene meter), so the pair alone cannot say // which chip is active — the last tapped preset is remembered to disambiguate. const WB_PAIRS: Record = { auto: { t: 5500, ti: 0 }, daylight: { t: 5500, ti: 0 }, 'daylight-3r': { t: 5500, ti: -3 }, cloudy: { t: 6500, ti: 1 }, shade: { t: 8000, ti: 2 }, tungsten: { t: 3200, ti: 0 }, fluorescent: { t: 4000, ti: 3 }, }; // WATERMARK tab styling choices: swatches for TEXT COLOR, multipliers on the // base 3.2%-of-frame-width size for TEXT SIZE. const WM_DEFAULT_COLOR = '#f59e0b'; const WM_COLORS: { v: string; d: string }[] = [ { v: '#f59e0b', d: 'AMBER' }, { v: '#ffffff', d: 'WHITE' }, { v: '#111111', d: 'BLACK' }, { v: '#ef4444', d: 'RED' }, { v: '#3b82f6', d: 'BLUE' }, { v: '#22c55e', d: 'GREEN' }, { v: '#a1a1aa', d: 'GREY' }, ]; const WM_SIZES: { v: string; d: string }[] = [ { v: '0.6', d: 'S' }, { v: '1', d: 'M' }, { v: '1.6', d: 'L' }, { v: '2.5', d: 'XL' }, ]; export default function AdjustmentPanel({ activeTab, recipes, currentRecipeId, adjustments, selectedFrame, useGeotag, openParam, onOpenParamChange, currentBaseFilter, onCreateRecipe, onSelectRecipe, onUpdateAdjustments, onUpdateFrame, photoRotation, onToggleGeotag, onDeleteRecipe, customWm, onUpdateCustomWm, onAcceptCustomWm, onCancelCustomWm, wmEditNonce, onToggleRotation, }: AdjustmentPanelProps) { // Double-tap on the mark in the viewfinder bumps wmEditNonce -> focus the // input again so the text can be retyped after the keyboard was dismissed. const wmInputRef = useRef(null); useEffect(() => { if (wmEditNonce > 0) { // BACK hides the IME but leaves the input focused, so a bare focus() is a // no-op and the keyboard never returns. Blur first so the re-focus is a // real focus change the IME reacts to. wmInputRef.current?.blur(); const id = setTimeout(() => wmInputRef.current?.focus(), 60); return () => clearTimeout(id); } }, [wmEditNonce]); // The single choice-group whose strip (OFF/WEAK/STRONG, or the DR ladder) is // open above the chips (same one-row-at-a-time pattern as openParam — which // lives in App now, since an open row also arms image-drag adjustment). const [openGroup, setOpenGroup] = useState(null); // PRESETS tab only: RECIPES chip expands the user-recipe strip above the main // row; CREATE chip opens the quick-input modal. const [showRecipes, setShowRecipes] = useState(false); const [createVisible, setCreateVisible] = useState(false); // WB tab: preset last tapped while its engine pair is still in effect (AUTO // vs DAYLIGHT share the neutral pair, so selection can't be derived). const [wbChoice, setWbChoice] = useState(null); useEffect(() => { setOpenGroup(null); setShowRecipes(false); setCreateVisible(false); }, [activeTab]); const toggleParam = (key: string) => { haptic(); // One auxiliary row at a time: opening a slider closes any option strip. setOpenGroup(null); onOpenParamChange(openParam === key ? null : key); }; const toggleGroup = (g: GroupKey) => { haptic(); const next = openGroup === g ? null : g; // Opening an options strip closes any open slider row (single-row rule). if (next) onOpenParamChange(null); setOpenGroup(next); }; const a = adjustments; const renderChip = (c: ChipDef) => { const tone = c.active ? 'border-amber-500/70 bg-amber-500/15' : 'border-zinc-700/60 bg-titan/80'; const textTone = c.active || c.amberValue ? 'text-amber-500' : 'text-zinc-300'; return ( {c.trash ? ( { haptic(); c.onPress(); }} activeOpacity={0.7}> {c.label} { e.stopPropagation(); haptic(); c.onPressDelete && c.onPressDelete(); }} className="p-1.5 ml-1" hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} > ) : c.disabled ? ( {c.label} ) : ( { haptic(); c.onPress(); }} className={`flex-row items-center rounded-full border px-3 py-1.5 ${tone}`} activeOpacity={0.7} > {c.label} )} ); }; // Continuous parameter chips: label + live value; amber text when edited; // amber border while its slider row is open. const paramChips = (params: ParamDef[]) => params.map((p) => renderChip({ key: p.key, label: `${p.label.toUpperCase()} ${p.display(p.value)}`, active: openParam === p.key, amberValue: openParam !== p.key && p.value !== p.default, onPress: () => toggleParam(p.key), }) ); const choiceChips = ( keyPrefix: string, options: { v: string; d: string }[], current: string, onPick: (v: string) => void ) => options.map((o) => renderChip({ key: `${keyPrefix}:${o.v}`, label: o.d, active: current === o.v, onPress: () => onPick(o.v), }) ); // ---- per-tab parameter definitions --------------------------------------- // Static bounds/labels/accessors live in PARAM_DEFS (shared with App, which // needs the same metadata to drive drag-anywhere adjustment); here each row // is bound to the live adjustments value + an update callback. const toRows = (defs: ContinuousParamDef[]): ParamDef[] => defs.map((d) => ({ key: d.key, label: d.label, value: d.get(a), default: d.defaultValue, min: d.min, max: d.max, step: d.step, display: d.display, onChange: (v: number) => onUpdateAdjustments(d.set(v)), })); const paramDefs: Record = { recipes: [], iq: toRows(PARAM_DEFS.iq), wb: toRows(PARAM_DEFS.wb), filters: toRows(PARAM_DEFS.filters), frame: [], watermark: [], }; // Active WB chip: the remembered preset wins while the engine pair still // matches it; otherwise derive from the pair (neutral ⇒ AUTO; any unlisted // pair also reads as AUTO — no preset is a stronger claim there). const wbActive = (): string => { const t = a.temperature ?? 5500; const ti = a.tint ?? 0; if (wbChoice) { const p = WB_PAIRS[wbChoice]; if (p && p.t === t && p.ti === ti) return wbChoice; } const hit = (Object.keys(WB_PAIRS) as string[]).find((k) => { const p = WB_PAIRS[k]; return p.t === t && p.ti === ti; }); return hit ?? 'auto'; }; const wbApply = (val: string) => { setWbChoice(val); // Choosing a preset is a deliberate look switch: collapse any open chrome // options strip so it never lingers above the chips row. setOpenGroup(null); const p = WB_PAIRS[val] ?? WB_PAIRS.auto; onUpdateAdjustments({ temperature: p.t, tint: p.ti }); }; // Choice-groups: one toggle chip sits in the chip row (COLOR CHROME / // CHROME BLUE among the WB chips, D.RANGE after the LIGHT params); tapping it // reveals a strip above the chip row with that group's options. Amber text // while a value differs from the group's off value, amber border while its // strip is open — same affordances as the parameter chips. const chromeOptions = [ { v: 'none', d: 'OFF' }, { v: 'weak', d: 'WEAK' }, { v: 'strong', d: 'STRONG' }, ]; const drOptions = [ { v: 'auto', d: 'AUTO' }, { v: '100', d: 'DR100' }, { v: '200', d: 'DR200' }, { v: '400', d: 'DR400' }, ]; // WATERMARK tab styling strips. FONT lists every family the PLATFORM font // manager exposes (the device's own system fonts); DEFAULT keeps the face // the mark shipped with. const wmFontOptions = useMemo(() => { const mgr = Skia.FontMgr.System(); const names = new Set(); for (let i = 0; i < mgr.countFamilies() && names.size < 120; i++) { const n = mgr.getFamilyName(i); if (n && !/fallback/i.test(n)) names.add(n); } return [ { v: 'DEFAULT', d: 'DEFAULT' }, ...[...names].sort().map((n) => ({ v: n, d: n })), ]; }, []); const groupDefs: Record void }> = { cx: { label: 'COLOR CHROME', off: 'none', value: a.colorChrome || 'none', options: chromeOptions, onPick: (v) => onUpdateAdjustments({ colorChrome: v as ColorAdjustments['colorChrome'] }), }, cxb: { label: 'CHROME BLUE', off: 'none', value: a.colorChromeBlue || 'none', options: chromeOptions, onPick: (v) => onUpdateAdjustments({ colorChromeBlue: v as ColorAdjustments['colorChromeBlue'] }), }, dr: { label: 'D.RANGE', off: 'auto', value: String(a.dynamicRange ?? 'auto'), options: drOptions, onPick: (v) => onUpdateAdjustments({ dynamicRange: (v === 'auto' ? 'auto' : parseInt(v, 10)) as ColorAdjustments['dynamicRange'], }), }, wmColor: { label: 'TEXT COLOR', off: WM_DEFAULT_COLOR, value: customWm.color ?? WM_DEFAULT_COLOR, options: WM_COLORS, onPick: (v) => onUpdateCustomWm({ ...customWm, color: v }), }, wmSize: { label: 'TEXT SIZE', off: '1', value: String(customWm.size ?? 1), options: WM_SIZES, onPick: (v) => onUpdateCustomWm({ ...customWm, size: Number(v) }), }, wmFont: { label: 'TEXT FONT', off: 'DEFAULT', value: customWm.font ?? 'DEFAULT', options: wmFontOptions, onPick: (v) => onUpdateCustomWm({ ...customWm, font: v === 'DEFAULT' ? null : v }), }, }; const groupChip = (g: GroupKey): ChipDef => ({ key: `grp-${g}`, label: groupDefs[g].label, active: openGroup === g, amberValue: openGroup !== g && groupDefs[g].value !== groupDefs[g].off, onPress: () => toggleGroup(g), }); // ---- per-tab chip rows (bottom, above the tool rail) ----------------------- const chipsRow = (() => { switch (activeTab) { case 'recipes': { // Main row: the six standard film simulations, then the RECIPES toggle // (expands the user/bundled recipe strip above) and CREATE (quick-input // modal). Recipes themselves are no longer inline chips here. const chips: ChipDef[] = [ ...FILM_SIMS.map((s) => ({ key: s.id, label: s.name, active: currentRecipeId === s.id, // Choosing a simulation is a deliberate switch away from the saved // recipes: collapse the RECIPES strip above so it never lingers // over the freshly-applied sim. onPress: () => { setShowRecipes(false); onSelectRecipe(filmSimToRecipe(s)); }, })), { key: 'recipes-toggle', label: 'RECIPES', active: showRecipes, onPress: () => setShowRecipes((v) => !v), }, { key: 'create', label: 'CREATE', active: false, onPress: () => setCreateVisible(true), }, ]; return ( {chips.map(renderChip)} ); } case 'iq': // LIGHT: continuous params on one row; D.RANGE is a toggle chip that // opens its AUTO/DR100/DR200/DR400 strip above (same as the chrome // groups on WB). return ( {paramChips(paramDefs.iq)} {renderChip(groupChip('dr'))} ); case 'wb': return ( {choiceChips( 'wb', [ { v: 'auto', d: 'AUTO' }, { v: 'daylight', d: 'DAYLIGHT' }, { v: 'daylight-3r', d: 'DAYLIGHT -3R' }, { v: 'cloudy', d: 'CLOUDY' }, { v: 'shade', d: 'SHADE' }, { v: 'tungsten', d: 'TUNGSTEN' }, { v: 'fluorescent', d: 'FLUOR' }, ], wbActive(), wbApply )} {paramChips(paramDefs.wb)} {(['cx', 'cxb'] as const).map((g) => renderChip(groupChip(g)))} ); case 'filters': return ( {paramChips(paramDefs.filters)} ); case 'frame': return ( {choiceChips( 'fr', FRAMES.map((f) => ({ v: f.id, d: f.name.toUpperCase() })), selectedFrame, (val) => onUpdateFrame(val as FrameId) )} {renderChip({ key: 'rotate', label: photoRotation === 0 ? 'ROTATE 90' : 'ROTATE 0', active: photoRotation !== 0, onPress: onToggleRotation, })} ); case 'watermark': return ( {renderChip({ key: 'gps', label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF', active: useGeotag, onPress: () => onToggleGeotag(!useGeotag), })} {renderChip({ key: 'custom-wm', label: customWm.enabled ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF', active: customWm.enabled, onPress: () => onUpdateCustomWm({ ...customWm, enabled: !customWm.enabled }), })} {customWm.enabled && renderChip({ key: 'custom-wm-accept', label: 'ACCEPT', active: true, onPress: onAcceptCustomWm, })} {customWm.enabled && renderChip({ key: 'custom-wm-cancel', label: 'CANCEL', active: false, onPress: onCancelCustomWm, })} {customWm.enabled && renderChip(groupChip('wmColor'))} {customWm.enabled && renderChip(groupChip('wmSize'))} {customWm.enabled && renderChip(groupChip('wmFont'))} ); default: return null; } })(); // ---- the single open slider row (ABOVE the chips row) ---------------------- const openParamDef = paramDefs[activeTab].find((p) => p.key === openParam) ?? null; return ( {openParamDef && ( toggleParam(openParamDef.key)} className="mr-3 rounded-full border border-zinc-700 bg-titan px-3 py-2" activeOpacity={0.7} > < {openParamDef.label} {openParamDef.display(openParamDef.value)} )} {openGroup && ( {renderChip({ key: `grp-hint-${openGroup}`, label: groupDefs[openGroup].label, disabled: true, active: false, onPress: () => {}, })} {choiceChips(openGroup, groupDefs[openGroup].options, groupDefs[openGroup].value, groupDefs[openGroup].onPick)} )} {/* PRESETS: expanded recipe strip (above the main chip row) while the RECIPES chip is open — bundled defaults + user-created, custom deletable inline. */} {activeTab === 'recipes' && showRecipes && ( {recipes.length === 0 ? renderChip({ key: 'no-recipes', label: 'NO RECIPES YET - TAP CREATE', disabled: true, active: false, onPress: () => {}, }) : recipes.map((r) => renderChip({ key: r.id, label: r.name, active: r.id === currentRecipeId, trash: !!r.isCustom, onPress: () => onSelectRecipe(r), onPressDelete: () => onDeleteRecipe(r.id), }) )} )} {activeTab === 'watermark' && customWm.enabled && ( onUpdateCustomWm({ ...customWm, text })} placeholder="TYPE WATERMARK TEXT" placeholderTextColor="#a1a1aa" className="rounded-md border border-amber-500/70 bg-black/40 px-3 py-2 font-mono text-sm font-bold text-amber-300" maxLength={80} /> )} {chipsRow} setCreateVisible(false)} onSave={(recipe) => { onCreateRecipe(recipe); setShowRecipes(true); }} /> ); }