feat: v1.1 — highlight-weighted metering, settings modal, photo viewer, shutter sound control, cleanup
This commit is contained in:
+145
-103
@@ -3,8 +3,11 @@ import { View, Text, 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 { Recipe, ColorAdjustments, FrameId, AspectRatio } from '../types';
|
||||
import { Recipe, ColorAdjustments, 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
|
||||
@@ -12,7 +15,7 @@ import { FRAMES } from '../utils/frameUtils';
|
||||
// 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' | 'ratio';
|
||||
type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame';
|
||||
// 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';
|
||||
@@ -24,16 +27,23 @@ interface AdjustmentPanelProps {
|
||||
adjustments: ColorAdjustments;
|
||||
selectedFrame: FrameId;
|
||||
useGeotag: boolean;
|
||||
aspectRatio: AspectRatio;
|
||||
rawEnabled: boolean;
|
||||
// RAW (DNG sidecar) is only meaningful while the live camera is the source.
|
||||
cameraMode: 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<Recipe, 'id' | 'isCustom'>) => void;
|
||||
onSelectRecipe: (recipe: Recipe) => void;
|
||||
onUpdateAdjustments: (adjustments: Partial<ColorAdjustments>) => void;
|
||||
onUpdateFrame: (frameId: FrameId) => void;
|
||||
onToggleGeotag: (enabled: boolean) => void;
|
||||
onAspectRatio: (ratio: AspectRatio) => void;
|
||||
onRawEnabled: (enabled: boolean) => void;
|
||||
onSaveRecipe: (name: string) => void;
|
||||
onDeleteRecipe: (id: string) => void;
|
||||
}
|
||||
@@ -62,8 +72,19 @@ interface ParamDef {
|
||||
}
|
||||
|
||||
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
const sign = (v: number) => (v > 0 ? `+${v}` : String(v));
|
||||
const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`;
|
||||
|
||||
// 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<string, { t: number; ti: number }> = {
|
||||
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 },
|
||||
};
|
||||
|
||||
export default function AdjustmentPanel({
|
||||
activeTab,
|
||||
@@ -72,31 +93,36 @@ export default function AdjustmentPanel({
|
||||
adjustments,
|
||||
selectedFrame,
|
||||
useGeotag,
|
||||
aspectRatio,
|
||||
rawEnabled,
|
||||
cameraMode,
|
||||
openParam,
|
||||
onOpenParamChange,
|
||||
currentBaseFilter,
|
||||
onCreateRecipe,
|
||||
onSelectRecipe,
|
||||
onUpdateAdjustments,
|
||||
onUpdateFrame,
|
||||
onToggleGeotag,
|
||||
onAspectRatio,
|
||||
onRawEnabled,
|
||||
onDeleteRecipe,
|
||||
}: AdjustmentPanelProps) {
|
||||
// The single parameter whose slider row is open — a new selection replaces
|
||||
// the previous row (one row at a time, above the chips).
|
||||
const [openParam, setOpenParam] = useState<string | null>(null);
|
||||
// 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).
|
||||
// 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<GroupKey | null>(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<string | null>(null);
|
||||
useEffect(() => {
|
||||
setOpenParam(null);
|
||||
setOpenGroup(null);
|
||||
setShowRecipes(false);
|
||||
setCreateVisible(false);
|
||||
}, [activeTab]);
|
||||
|
||||
const toggleParam = (key: string) => {
|
||||
haptic();
|
||||
setOpenParam((prev) => (prev === key ? null : key));
|
||||
onOpenParamChange(openParam === key ? null : key);
|
||||
};
|
||||
|
||||
const toggleGroup = (g: GroupKey) => {
|
||||
@@ -178,51 +204,49 @@ export default function AdjustmentPanel({
|
||||
);
|
||||
|
||||
// ---- 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<TabId, ParamDef[]> = {
|
||||
recipes: [],
|
||||
iq: [
|
||||
{ key: 'exposure', label: 'EXPOSURE', value: a.exposure, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ exposure: v }) },
|
||||
{ key: 'ev', label: 'EV', value: a.exposureCompensation ?? 0, default: 0, min: -3, max: 3, step: 0.5, display: oneEv, onChange: (v) => onUpdateAdjustments({ exposureCompensation: v }) },
|
||||
{ key: 'contrast', label: 'CONTRAST', value: a.contrast, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ contrast: v }) },
|
||||
{ key: 'color', label: 'COLOR', value: a.saturation, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ saturation: v }) },
|
||||
{ key: 'highlight', label: 'HIGHLIGHT', value: a.highlight ?? 0, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ highlight: v }) },
|
||||
{ key: 'shadow', label: 'SHADOW', value: a.shadow ?? 0, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ shadow: v }) },
|
||||
],
|
||||
wb: [
|
||||
{ key: 'temperature', label: 'COLOR TEMP', value: a.temperature, default: 5500, min: 2500, max: 10000, step: 100, display: (v) => `${v}K`, onChange: (v) => onUpdateAdjustments({ temperature: v }) },
|
||||
{ key: 'tint', label: 'TINT', value: a.tint ?? 0, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ tint: v }) },
|
||||
],
|
||||
filters: [
|
||||
{ key: 'denoise', label: 'NOISE REDUCTION', value: a.denoise, default: 0, min: 0, max: 10, step: 1, display: String, onChange: (v) => onUpdateAdjustments({ denoise: v }) },
|
||||
{ key: 'clarity', label: 'CLARITY', value: a.clarity, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ clarity: v }) },
|
||||
{ key: 'sharpening', label: 'SHARPENING', value: a.sharpening ?? 0, default: 0, min: 0, max: 10, step: 1, display: String, onChange: (v) => onUpdateAdjustments({ sharpening: v }) },
|
||||
{ key: 'grain', label: 'MONOCHROME GRAIN', value: a.grain, default: 0, min: 0, max: 10, step: 1, display: String, onChange: (v) => onUpdateAdjustments({ grain: v }) },
|
||||
],
|
||||
iq: toRows(PARAM_DEFS.iq),
|
||||
wb: toRows(PARAM_DEFS.wb),
|
||||
filters: toRows(PARAM_DEFS.filters),
|
||||
frame: [],
|
||||
ratio: [],
|
||||
};
|
||||
|
||||
const wbPresetOf = (): string => {
|
||||
// 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 (t === 5500 && ti === -3) return 'daylight-3r';
|
||||
if (t === 6500 && ti === 1) return 'cloudy';
|
||||
if (t === 8000 && ti === 2) return 'shade';
|
||||
if (t === 3200 && ti === 0) return 'tungsten';
|
||||
if (t === 4000 && ti === 3) return 'fluorescent';
|
||||
if (t === 5500 && ti === 0) return 'daylight';
|
||||
return 'auto';
|
||||
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) => {
|
||||
switch (val) {
|
||||
case 'daylight': return onUpdateAdjustments({ temperature: 5500, tint: 0 });
|
||||
case 'daylight-3r': return onUpdateAdjustments({ temperature: 5500, tint: -3 });
|
||||
case 'cloudy': return onUpdateAdjustments({ temperature: 6500, tint: 1 });
|
||||
case 'shade': return onUpdateAdjustments({ temperature: 8000, tint: 2 });
|
||||
case 'tungsten': return onUpdateAdjustments({ temperature: 3200, tint: 0 });
|
||||
case 'fluorescent': return onUpdateAdjustments({ temperature: 4000, tint: 3 });
|
||||
default: return onUpdateAdjustments({ temperature: 5500, tint: 0 });
|
||||
}
|
||||
setWbChoice(val);
|
||||
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 /
|
||||
@@ -270,21 +294,29 @@ export default function AdjustmentPanel({
|
||||
const chipsRow = (() => {
|
||||
switch (activeTab) {
|
||||
case 'recipes': {
|
||||
if (recipes.length === 0) {
|
||||
return (
|
||||
<View className="px-3 py-2">
|
||||
<Text className="font-mono text-[10px] text-zinc-500">NO PRESETS - TAP SAVE ABOVE</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const chips: ChipDef[] = recipes.map((r) => ({
|
||||
key: r.id,
|
||||
label: r.name,
|
||||
active: r.id === currentRecipeId,
|
||||
trash: !!r.isCustom,
|
||||
onPress: () => onSelectRecipe(r),
|
||||
onPressDelete: () => onDeleteRecipe(r.id),
|
||||
}));
|
||||
// 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,
|
||||
onPress: () => onSelectRecipe(filmSimToRecipe(s)),
|
||||
})),
|
||||
{
|
||||
key: 'recipes-toggle',
|
||||
label: 'RECIPES',
|
||||
active: showRecipes,
|
||||
onPress: () => setShowRecipes((v) => !v),
|
||||
},
|
||||
{
|
||||
key: 'create',
|
||||
label: 'CREATE',
|
||||
active: false,
|
||||
onPress: () => setCreateVisible(true),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
@@ -330,7 +362,7 @@ export default function AdjustmentPanel({
|
||||
{ v: 'tungsten', d: 'TUNGSTEN' },
|
||||
{ v: 'fluorescent', d: 'FLUOR' },
|
||||
],
|
||||
wbPresetOf(),
|
||||
wbActive(),
|
||||
wbApply
|
||||
)}
|
||||
{paramChips(paramDefs.wb)}
|
||||
@@ -372,36 +404,6 @@ export default function AdjustmentPanel({
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
case 'ratio':
|
||||
// Composition aspect (FULL = current fullscreen, 4:3 / 3:2 are the
|
||||
// upright photo ratios) + RAW DNG sidecar toggle (camera captures
|
||||
// only — dimmed in library mode where the shutter exports a still).
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
||||
>
|
||||
{renderChip({
|
||||
key: 'raw',
|
||||
label: rawEnabled ? 'RAW ON' : 'RAW OFF',
|
||||
active: rawEnabled,
|
||||
disabled: !cameraMode,
|
||||
onPress: () => onRawEnabled(!rawEnabled),
|
||||
})}
|
||||
{choiceChips(
|
||||
'ratio',
|
||||
[
|
||||
{ v: 'full', d: 'FULL' },
|
||||
{ v: '4:3', d: '4:3' },
|
||||
{ v: '3:2', d: '3:2' },
|
||||
],
|
||||
aspectRatio,
|
||||
(v) => onAspectRatio(v as AspectRatio)
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -469,7 +471,47 @@ export default function AdjustmentPanel({
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{/* 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 && (
|
||||
<View className="border-b border-zinc-800/60">
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
|
||||
>
|
||||
{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),
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{chipsRow}
|
||||
<RecipeCreateModal
|
||||
visible={createVisible}
|
||||
currentBase={currentBaseFilter}
|
||||
onClose={() => setCreateVisible(false)}
|
||||
onSave={(recipe) => {
|
||||
onCreateRecipe(recipe);
|
||||
setShowRecipes(true);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user