feat: v1.1 — highlight-weighted metering, settings modal, photo viewer, shutter sound control, cleanup
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
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 PROVIA.
|
||||
currentBase?: BaseFilter;
|
||||
onClose: () => void;
|
||||
onSave: (recipe: Omit<Recipe, 'id' | 'isCustom'>) => 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) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => { 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}
|
||||
>
|
||||
<Text
|
||||
className={`font-mono text-[10px] font-bold tracking-wide ${
|
||||
disabled ? 'text-zinc-600' : active ? 'text-amber-500' : 'text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const ChipRow = ({ children }: { children: React.ReactNode }) => (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingVertical: 2 }}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
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) => (
|
||||
<View style={{ width: '48%' }} className="mb-1.5">
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">
|
||||
{label}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
placeholder={placeholder ?? '0'}
|
||||
placeholderTextColor="#52525b"
|
||||
className="bg-carbon border border-zinc-800 rounded-md px-2 py-1 text-white font-mono text-sm text-right"
|
||||
style={{ width }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
export default function RecipeCreateModal({
|
||||
visible,
|
||||
currentBase,
|
||||
onClose,
|
||||
onSave,
|
||||
}: RecipeCreateModalProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
// Seed from the current film sim (or PROVIA) so "other params" start at the
|
||||
// sim's standard values and the user only types what they want to change.
|
||||
const seedBase: BaseFilter =
|
||||
currentBase && FILM_SIMS.some((s) => s.baseFilter === currentBase) ? currentBase : 'provia';
|
||||
const seedAdj = 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) => {
|
||||
const s = FILM_SIMS.find((x) => x.baseFilter === b)?.adjustments ?? DEFAULT_ADJUSTMENTS;
|
||||
return {
|
||||
dr: String(s.dynamicRange ?? 'auto'),
|
||||
grain: String(s.grain === 0 ? 0 : s.grain >= 6 ? 6 : 3),
|
||||
cc: s.colorChrome ?? 'none',
|
||||
ccb: s.colorChromeBlue ?? 'none',
|
||||
wbKey: WB_KEYS.find((k) => {
|
||||
const p = WB_PRESETS.find((w) => w.key === k)!;
|
||||
return p.temperature === s.temperature && p.tint === s.tint;
|
||||
}) ?? 'auto',
|
||||
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<BaseFilter>(seedBase);
|
||||
const [name, setName] = useState('');
|
||||
const [dr, setDr] = useState<string>(seedAdj.dynamicRange == null ? 'auto' : String(seedAdj.dynamicRange));
|
||||
const [grain, setGrain] = useState<string>(String(seedAdj.grain === 0 ? 0 : seedAdj.grain >= 6 ? 6 : 3));
|
||||
const [cc, setCc] = useState<string>(seedAdj.colorChrome ?? 'none');
|
||||
const [ccb, setCcb] = useState<string>(seedAdj.colorChromeBlue ?? 'none');
|
||||
const [wbKey, setWbKey] = useState<string>(
|
||||
WB_KEYS.find((k) => {
|
||||
const p = WB_PRESETS.find((w) => w.key === k)!;
|
||||
return p.temperature === seedAdj.temperature && p.tint === seedAdj.tint;
|
||||
}) ?? 'auto'
|
||||
);
|
||||
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<number | null>(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;
|
||||
const v = valuesOf(seedBase);
|
||||
setSim(seedBase);
|
||||
setDr(v.dr);
|
||||
setGrain(v.grain);
|
||||
setCc(v.cc);
|
||||
setCcb(v.ccb);
|
||||
setWbKey(v.wbKey);
|
||||
setWbRed('0');
|
||||
setWbBlue('0');
|
||||
setFHigh(v.highlight);
|
||||
setFShadow(v.shadow);
|
||||
setFColor(v.color);
|
||||
setFNr(v.nr);
|
||||
setFSharp(v.sharp);
|
||||
setFClarity(v.clarity);
|
||||
setFEv(v.ev);
|
||||
setName('');
|
||||
setIso(null);
|
||||
}, [visible, currentBase]);
|
||||
|
||||
// Switching the simulation reloads its standard values into the form.
|
||||
const selectSim = (b: BaseFilter) => {
|
||||
setSim(b);
|
||||
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) ?? WB_PRESETS[0];
|
||||
const simAdj = FILM_SIMS.find((s) => s.baseFilter === sim)?.adjustments ?? DEFAULT_ADJUSTMENTS;
|
||||
const adj: ColorAdjustments = {
|
||||
...DEFAULT_ADJUSTMENTS,
|
||||
...simAdj,
|
||||
temperature: wb.temperature,
|
||||
tint: wb.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: parseInt(grain, 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() || 'NEW RECIPE',
|
||||
baseFilter: sim,
|
||||
adjustments: adj,
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
});
|
||||
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 (
|
||||
<Modal transparent animationType="slide" visible={visible} onRequestClose={onClose}>
|
||||
<View className="absolute inset-0 bg-black/60" />
|
||||
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||
<View
|
||||
style={{ paddingTop: insets.top + 12, paddingBottom: insets.bottom + 12, maxHeight: '100%' }}
|
||||
className="bg-titan border-b border-zinc-800 rounded-b-3xl px-4 pt-1"
|
||||
>
|
||||
<View className="flex-row items-center justify-between mb-2">
|
||||
<Text className="text-white font-mono text-sm font-bold tracking-wider">CREATE RECIPE</Text>
|
||||
<TouchableOpacity onPress={onClose} className="px-2 py-1" activeOpacity={0.7}>
|
||||
<Text className="text-zinc-400 font-mono text-xs font-bold">✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 8 }}>
|
||||
{/* Name */}
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">NAME</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Recipe name (e.g. Fuji Retro)"
|
||||
placeholderTextColor="#71717a"
|
||||
className="bg-carbon border border-zinc-800 rounded-lg p-2 text-white font-mono text-sm mb-2"
|
||||
/>
|
||||
|
||||
{/* Simulation */}
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">SIMULATION</Text>
|
||||
<ChipRow>
|
||||
{FILM_SIMS.map((s) => (
|
||||
<Chip key={s.id} label={s.name} active={sim === s.baseFilter} onPress={() => selectSim(s.baseFilter)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
|
||||
{/* Dynamic range */}
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">DYNAMIC RANGE</Text>
|
||||
<ChipRow>
|
||||
{DR_OPTS.map((o) => (
|
||||
<Chip key={o.v} label={o.d} active={dr === o.v} onPress={() => setDr(o.v)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
|
||||
{/* Numeric textboxes — 2 per row */}
|
||||
<View className="flex-row flex-wrap justify-between mt-1.5">
|
||||
{fieldSets.map((f) => (
|
||||
<NumRow key={f.label} label={f.label} value={f.value} onChange={f.set} placeholder={f.ph} />
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Grain / chrome */}
|
||||
<View className="mt-1">
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">GRAIN EFFECT</Text>
|
||||
<ChipRow>
|
||||
{GRAIN_OPTS.map((o) => (
|
||||
<Chip key={o.v} label={o.d} active={grain === o.v} onPress={() => setGrain(o.v)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">COLOR CHROME EFFECT</Text>
|
||||
<ChipRow>
|
||||
{CC_OPTS.map((o) => (
|
||||
<Chip key={o.v} label={o.d} active={cc === o.v} onPress={() => setCc(o.v)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">COLOR CHROME EFFECT BLUE</Text>
|
||||
<ChipRow>
|
||||
{CC_OPTS.map((o) => (
|
||||
<Chip key={o.v} label={o.d} active={ccb === o.v} onPress={() => setCcb(o.v)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
</View>
|
||||
|
||||
{/* White balance + R/B shift */}
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">WHITE BALANCE</Text>
|
||||
<ChipRow>
|
||||
{WB_PRESETS.map((w) => (
|
||||
<Chip key={w.key} label={w.label} active={wbKey === w.key} onPress={() => setWbKey(w.key)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
<View className="flex-row items-center mt-1">
|
||||
<Text style={{ width: LABEL_W }} className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest">
|
||||
WB SHIFT
|
||||
</Text>
|
||||
<Text className="text-zinc-500 font-mono text-[9px] mr-1.5">RED</Text>
|
||||
<TextInput
|
||||
value={wbRed}
|
||||
onChangeText={setWbRed}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
placeholder="-9..9"
|
||||
placeholderTextColor="#52525b"
|
||||
className="bg-carbon border border-zinc-800 rounded-md px-2 py-1 text-white font-mono text-sm text-right mr-3"
|
||||
style={{ width: 52 }}
|
||||
/>
|
||||
<Text className="text-zinc-500 font-mono text-[9px] mr-1.5">BLUE</Text>
|
||||
<TextInput
|
||||
value={wbBlue}
|
||||
onChangeText={setWbBlue}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
placeholder="-9..9"
|
||||
placeholderTextColor="#52525b"
|
||||
className="bg-carbon border border-zinc-800 rounded-md px-2 py-1 text-white font-mono text-sm text-right"
|
||||
style={{ width: 52 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* ISO */}
|
||||
<View className="flex-row items-center mt-1.5 mb-1">
|
||||
<Text style={{ width: LABEL_W }} className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest">
|
||||
ISO
|
||||
</Text>
|
||||
{ISO_SUPPORTED ? (
|
||||
<ChipRow>
|
||||
<Chip label="AUTO" active={iso === null} onPress={() => setIso(null)} />
|
||||
{ISO_STOPS.map((s) => (
|
||||
<Chip key={s} label={String(s)} active={iso === s} onPress={() => setIso(s)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
) : (
|
||||
<Chip label="AUTO (camera default)" active disabled onPress={() => {}} />
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View className="flex-row space-x-3 mt-2">
|
||||
<TouchableOpacity onPress={onClose} className="flex-1 bg-zinc-800 py-3 rounded-lg items-center" activeOpacity={0.7}>
|
||||
<Text className="text-zinc-300 font-mono text-xs font-semibold">CANCEL</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleSave} className="flex-1 bg-amber-500 py-3 rounded-lg items-center" activeOpacity={0.7}>
|
||||
<Text className="text-black font-mono text-xs font-bold">SAVE RECIPE</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user