fix(camera): matrix-based zoom for pinch/double-tap, tap-to-focus AE/AF lock, library letterbox smear fix, export raw JPEG bytes q95, resume isActive fixes, dark theme polish
This commit is contained in:
+344
-325
@@ -1,21 +1,21 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
FlatList,
|
||||
TextInput,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
|
||||
import Slider from '@react-native-community/slider';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Trash2, Plus, Check } from 'lucide-react-native';
|
||||
import { Trash2 } from 'lucide-react-native';
|
||||
import { Recipe, ColorAdjustments, FrameId } from '../types';
|
||||
import { FRAMES } from '../utils/frameUtils';
|
||||
|
||||
// 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';
|
||||
|
||||
interface AdjustmentPanelProps {
|
||||
activeTab: 'recipes' | 'iq' | 'wb' | 'filters' | 'frame';
|
||||
activeTab: TabId;
|
||||
recipes: Recipe[];
|
||||
currentRecipeId: string;
|
||||
adjustments: ColorAdjustments;
|
||||
@@ -29,6 +29,33 @@ interface AdjustmentPanelProps {
|
||||
onDeleteRecipe: (id: string) => void;
|
||||
}
|
||||
|
||||
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);
|
||||
const sign = (v: number) => (v > 0 ? `+${v}` : String(v));
|
||||
const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`;
|
||||
|
||||
export default function AdjustmentPanel({
|
||||
activeTab,
|
||||
recipes,
|
||||
@@ -40,342 +67,334 @@ export default function AdjustmentPanel({
|
||||
onUpdateAdjustments,
|
||||
onUpdateFrame,
|
||||
onToggleGeotag,
|
||||
onSaveRecipe,
|
||||
onDeleteRecipe,
|
||||
}: AdjustmentPanelProps) {
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [newRecipeName, setNewRecipeName] = useState('');
|
||||
// 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);
|
||||
useEffect(() => {
|
||||
setOpenParam(null);
|
||||
}, [activeTab]);
|
||||
|
||||
const triggerHaptic = () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
const toggleParam = (key: string) => {
|
||||
haptic();
|
||||
setOpenParam((prev) => (prev === key ? null : key));
|
||||
};
|
||||
|
||||
const renderRecipeItem = React.useCallback(
|
||||
({ item }: { item: Recipe }) => (
|
||||
<RecipeItem
|
||||
item={item}
|
||||
isActive={item.id === currentRecipeId}
|
||||
onSelect={onSelectRecipe}
|
||||
onDelete={onDeleteRecipe}
|
||||
triggerHaptic={triggerHaptic}
|
||||
/>
|
||||
),
|
||||
[currentRecipeId, onSelectRecipe, onDeleteRecipe]
|
||||
);
|
||||
const a = adjustments;
|
||||
|
||||
const handleSliderValueChange = () => {
|
||||
// optional: light haptic while sliding, or only on completion.
|
||||
// Usually Haptic on sliding is too heavy, so we don't trigger it on every frame,
|
||||
// or we only trigger on sliding complete.
|
||||
};
|
||||
|
||||
const handleSavePress = () => {
|
||||
if (newRecipeName.trim()) {
|
||||
onSaveRecipe(newRecipeName.trim());
|
||||
setNewRecipeName('');
|
||||
setModalVisible(false);
|
||||
triggerHaptic();
|
||||
}
|
||||
};
|
||||
|
||||
const renderSlider = (
|
||||
label: string,
|
||||
value: number,
|
||||
min: number,
|
||||
max: number,
|
||||
step: number,
|
||||
onValueChange: (val: number) => void,
|
||||
displayValueMapper?: (val: number) => string
|
||||
) => {
|
||||
const displayVal = displayValueMapper ? displayValueMapper(value) : value.toString();
|
||||
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 (
|
||||
<View className="mb-4 px-4">
|
||||
<View className="flex-row justify-between items-center mb-1">
|
||||
<Text className="text-zinc-400 font-mono text-xs font-bold tracking-wider">{label.toUpperCase()}</Text>
|
||||
<Text className="text-amber-500 font-mono text-xs font-semibold">{displayVal}</Text>
|
||||
</View>
|
||||
<Slider
|
||||
value={value}
|
||||
minimumValue={min}
|
||||
maximumValue={max}
|
||||
step={step}
|
||||
onValueChange={onValueChange}
|
||||
onSlidingComplete={triggerHaptic}
|
||||
minimumTrackTintColor="#f59e0b"
|
||||
maximumTrackTintColor="#27272a"
|
||||
thumbTintColor="#f59e0b"
|
||||
/>
|
||||
<View key={c.key} className="mx-1">
|
||||
{c.trash ? (
|
||||
<View className={`flex-row items-center rounded-full border py-1.5 pl-3 pr-1 ${tone}`}>
|
||||
<TouchableOpacity onPress={() => { haptic(); c.onPress(); }} activeOpacity={0.7}>
|
||||
<Text className={`font-mono text-[11px] font-bold ${textTone}`}>{c.label}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
haptic();
|
||||
c.onPressDelete && c.onPressDelete();
|
||||
}}
|
||||
className="p-1.5 ml-1"
|
||||
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
||||
>
|
||||
<Trash2 size={12} color="#ef4444" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : c.disabled ? (
|
||||
<View className="mx-1 flex-row items-center rounded-full border border-zinc-800/80 bg-black/30 px-3 py-1.5">
|
||||
<Text className="font-mono text-[10px] font-semibold text-zinc-500">{c.label}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => { haptic(); c.onPress(); }}
|
||||
className={`flex-row items-center rounded-full border px-3 py-1.5 ${tone}`}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text className={`font-mono text-[11px] font-bold tracking-wide ${textTone}`}>
|
||||
{c.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
switch (activeTab) {
|
||||
case 'recipes':
|
||||
return (
|
||||
<View className="flex-1 bg-carbon">
|
||||
<View className="flex-row justify-between items-center px-4 py-2 bg-titan border-b border-zinc-800">
|
||||
<Text className="text-zinc-400 font-mono text-xs">SELECT OR CREATE PRESETS</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setModalVisible(true)}
|
||||
className="flex-row items-center bg-amber-500 px-3 py-1 rounded space-x-1"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Plus size={14} color="#000000" />
|
||||
<Text className="text-black font-mono text-xs font-bold">SAVE CURRENT</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
// 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),
|
||||
})
|
||||
);
|
||||
|
||||
<FlatList
|
||||
data={recipes}
|
||||
keyExtractor={(item) => item.id}
|
||||
numColumns={2}
|
||||
className="p-2"
|
||||
columnWrapperStyle={{ justifyContent: 'space-between' }}
|
||||
renderItem={renderRecipeItem}
|
||||
/>
|
||||
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),
|
||||
})
|
||||
);
|
||||
|
||||
{/* Save Recipe Modal */}
|
||||
<Modal
|
||||
transparent
|
||||
animationType="slide"
|
||||
visible={modalVisible}
|
||||
onRequestClose={() => setModalVisible(false)}
|
||||
// ---- per-tab parameter definitions ---------------------------------------
|
||||
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 }) },
|
||||
],
|
||||
frame: [],
|
||||
};
|
||||
|
||||
const wbPresetOf = (): 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';
|
||||
};
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
// ---- per-tab chip rows (bottom, above the tool rail) -----------------------
|
||||
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),
|
||||
}));
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
||||
>
|
||||
<View className="flex-1 justify-end bg-black/60">
|
||||
<View className="bg-titan border-t border-zinc-800 rounded-t-3xl p-6 pb-8">
|
||||
<Text className="text-white font-mono text-sm font-bold tracking-wider mb-4">
|
||||
SAVE NEW RECIPE
|
||||
</Text>
|
||||
<TextInput
|
||||
value={newRecipeName}
|
||||
onChangeText={setNewRecipeName}
|
||||
placeholder="Enter recipe name (e.g. Fuji Retro 🏮)"
|
||||
placeholderTextColor="#71717a"
|
||||
className="bg-carbon border border-zinc-800 rounded-lg p-3 text-white font-mono text-sm mb-4"
|
||||
autoFocus
|
||||
/>
|
||||
<View className="flex-row space-x-3">
|
||||
<TouchableOpacity
|
||||
onPress={() => setModalVisible(false)}
|
||||
className="flex-1 bg-zinc-800 py-3 rounded-lg items-center"
|
||||
>
|
||||
<Text className="text-zinc-300 font-mono text-xs font-semibold">CANCEL</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleSavePress}
|
||||
className="flex-1 bg-amber-500 py-3 rounded-lg items-center"
|
||||
>
|
||||
<Text className="text-black font-mono text-xs font-bold">SAVE RECIPE</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
{chips.map(renderChip)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
case 'iq':
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-carbon py-3">
|
||||
{renderSlider('Exposure', adjustments.exposure, -10, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ exposure: val })
|
||||
)}
|
||||
{renderSlider('Contrast', adjustments.contrast, -10, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ contrast: val })
|
||||
)}
|
||||
{renderSlider('Saturation', adjustments.saturation, -10, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ saturation: val })
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
case 'iq':
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
||||
>
|
||||
{paramChips(paramDefs.iq)}
|
||||
{renderChip({ key: 'sep-dr', label: 'DYNAMIC RANGE', disabled: true, active: false, onPress: () => {} })}
|
||||
{choiceChips(
|
||||
'dr',
|
||||
[
|
||||
{ v: 'auto', d: 'AUTO' },
|
||||
{ v: '100', d: 'DR100' },
|
||||
{ v: '200', d: 'DR200' },
|
||||
{ v: '400', d: 'DR400' },
|
||||
],
|
||||
String(a.dynamicRange ?? 'auto'),
|
||||
(val) =>
|
||||
onUpdateAdjustments({
|
||||
dynamicRange: (val === 'auto' ? 'auto' : parseInt(val, 10)) as ColorAdjustments['dynamicRange'],
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
case 'wb':
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-carbon py-3">
|
||||
{renderSlider(
|
||||
'Color Temp (Kelvin)',
|
||||
adjustments.temperature,
|
||||
2500,
|
||||
10000,
|
||||
100,
|
||||
(val) => onUpdateAdjustments({ temperature: val }),
|
||||
(val) => `${val}K`
|
||||
)}
|
||||
{renderSlider('Tint (Green-Magenta)', adjustments.tint, -10, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ tint: val })
|
||||
)}
|
||||
|
||||
<View className="px-4 mb-4">
|
||||
<Text className="text-zinc-400 font-mono text-xs font-bold tracking-wider mb-2">
|
||||
COLOR CHROME EFFECT
|
||||
</Text>
|
||||
<View className="flex-row bg-titan p-0.5 rounded-lg border border-zinc-800">
|
||||
{(['none', 'weak', 'strong'] as const).map((chrome) => {
|
||||
const isActive = adjustments.colorChrome === chrome;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={chrome}
|
||||
onPress={() => {
|
||||
onUpdateAdjustments({ colorChrome: chrome });
|
||||
triggerHaptic();
|
||||
}}
|
||||
className={`flex-1 py-1.5 rounded-md items-center ${
|
||||
isActive ? 'bg-amber-500' : 'bg-transparent'
|
||||
}`}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
className={`font-mono text-xs font-bold ${
|
||||
isActive ? 'text-black' : 'text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{chrome.toUpperCase()}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
case 'wb':
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
||||
>
|
||||
{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' },
|
||||
],
|
||||
wbPresetOf(),
|
||||
wbApply
|
||||
)}
|
||||
{paramChips(paramDefs.wb)}
|
||||
{renderChip({ key: 'sep-cx', label: 'COLOR CHROME', disabled: true, active: false, onPress: () => {} })}
|
||||
{choiceChips(
|
||||
'cx',
|
||||
[
|
||||
{ v: 'none', d: 'OFF' },
|
||||
{ v: 'weak', d: 'WEAK' },
|
||||
{ v: 'strong', d: 'STRONG' },
|
||||
],
|
||||
a.colorChrome || 'none',
|
||||
(val) => onUpdateAdjustments({ colorChrome: val as ColorAdjustments['colorChrome'] })
|
||||
)}
|
||||
{renderChip({ key: 'sep-cxb', label: 'CHROME BLUE', disabled: true, active: false, onPress: () => {} })}
|
||||
{choiceChips(
|
||||
'cxb',
|
||||
[
|
||||
{ v: 'none', d: 'OFF' },
|
||||
{ v: 'weak', d: 'WEAK' },
|
||||
{ v: 'strong', d: 'STRONG' },
|
||||
],
|
||||
a.colorChromeBlue || 'none',
|
||||
(val) => onUpdateAdjustments({ colorChromeBlue: val as ColorAdjustments['colorChromeBlue'] })
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
case 'filters':
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-carbon py-3">
|
||||
<Text className="text-zinc-600 font-mono text-[10px] px-4 mb-2">
|
||||
NOTE: DENOISE & CLARITY APPLY AT EXPORT ONLY — LIVE PREVIEW UNCHANGED
|
||||
</Text>
|
||||
{renderSlider('Denoise (Export)', adjustments.denoise, 0, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ denoise: val })
|
||||
)}
|
||||
{renderSlider('Clarity (Export)', adjustments.clarity, -10, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ clarity: val })
|
||||
)}
|
||||
{renderSlider('Monochrome Grain', adjustments.grain, 0, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ grain: val })
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
case 'filters':
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
||||
>
|
||||
{paramChips(paramDefs.filters)}
|
||||
{renderChip({
|
||||
key: 'note',
|
||||
label: 'NR/CLARITY/SHARPEN APPLY AT EXPORT',
|
||||
disabled: true,
|
||||
active: false,
|
||||
onPress: () => {},
|
||||
})}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
case 'frame':
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-carbon py-4">
|
||||
<View className="px-4 mb-5">
|
||||
<Text className="text-zinc-400 font-mono text-xs font-bold tracking-wider mb-3">
|
||||
SELECT PNG FRAME OVERLAY
|
||||
</Text>
|
||||
<View className="flex-row flex-wrap">
|
||||
{FRAMES.map((f: { id: FrameId; name: string }) => {
|
||||
const isActive = selectedFrame === f.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={f.id}
|
||||
onPress={() => {
|
||||
onUpdateFrame(f.id);
|
||||
triggerHaptic();
|
||||
}}
|
||||
className={`px-3 py-2 m-1 rounded border font-mono ${
|
||||
isActive
|
||||
? 'bg-amber-500/10 border-amber-500 text-amber-500'
|
||||
: 'bg-titan border-zinc-800 text-zinc-400'
|
||||
}`}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
className={`font-mono text-xs font-bold ${
|
||||
isActive ? 'text-amber-500' : 'text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{f.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
case 'frame':
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
||||
>
|
||||
{choiceChips(
|
||||
'fr',
|
||||
FRAMES.map((f) => ({ v: f.id, d: f.name.toUpperCase() })),
|
||||
selectedFrame,
|
||||
(val) => onUpdateFrame(val as FrameId)
|
||||
)}
|
||||
{renderChip({ key: 'sep-gps', label: 'GPS WATERMARK', disabled: true, active: false, onPress: () => {} })}
|
||||
{renderChip({
|
||||
key: 'gps',
|
||||
label: useGeotag ? 'GPS ON' : 'GPS OFF',
|
||||
active: useGeotag,
|
||||
onPress: () => onToggleGeotag(!useGeotag),
|
||||
})}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
<View className="px-4 flex-row items-center justify-between border-t border-zinc-800/50 pt-4">
|
||||
<View>
|
||||
<Text className="text-zinc-300 font-mono text-xs font-bold tracking-wider">
|
||||
GPS GEOTAG WATERMARK
|
||||
</Text>
|
||||
<Text className="text-zinc-500 font-mono text-[10px] mt-0.5">
|
||||
Stamps coordinates & place names onto photo
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
onToggleGeotag(!useGeotag);
|
||||
triggerHaptic();
|
||||
}}
|
||||
className={`w-12 h-6 rounded-full p-0.5 justify-center ${
|
||||
useGeotag ? 'bg-amber-500 items-end' : 'bg-zinc-800 items-start'
|
||||
}`}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View className="w-5 h-5 rounded-full bg-black border border-zinc-950 flex justify-center items-center">
|
||||
{useGeotag && <Check size={10} color="#f59e0b" />}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// ---- the single open slider row (ABOVE the chips row) ----------------------
|
||||
const openParamDef = paramDefs[activeTab].find((p) => p.key === openParam) ?? null;
|
||||
|
||||
interface RecipeItemProps {
|
||||
item: Recipe;
|
||||
isActive: boolean;
|
||||
onSelect: (recipe: Recipe) => void;
|
||||
onDelete: (id: string) => void;
|
||||
triggerHaptic: () => void;
|
||||
}
|
||||
|
||||
const RecipeItem = React.memo(({ item, isActive, onSelect, onDelete, triggerHaptic }: RecipeItemProps) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
onSelect(item);
|
||||
triggerHaptic();
|
||||
}}
|
||||
className={`flex-1 m-1.5 p-3 rounded-lg border ${
|
||||
isActive
|
||||
? 'bg-amber-500/10 border-amber-500'
|
||||
: 'bg-titan border-zinc-800/80'
|
||||
}`}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View className="flex-row justify-between items-start">
|
||||
<View className="flex-1 mr-1">
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className={`font-mono text-xs font-bold ${
|
||||
isActive ? 'text-amber-500' : 'text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text className="text-[10px] text-zinc-500 font-mono mt-0.5">
|
||||
{item.baseFilter.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
{item.isCustom && (
|
||||
<View>
|
||||
{openParamDef && (
|
||||
<View
|
||||
key={openParamDef.key}
|
||||
className="flex-row items-center border-b border-zinc-800/60 px-3 py-1.5"
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(item.id);
|
||||
triggerHaptic();
|
||||
}}
|
||||
className="p-1"
|
||||
onPress={() => toggleParam(openParamDef.key)}
|
||||
className="mr-3 rounded-full border border-zinc-700 bg-titan px-3 py-2"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Trash2 size={12} color="#ef4444" />
|
||||
<Text className="font-mono text-xs font-bold text-zinc-300"><</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View className="flex-1">
|
||||
<View className="flex-row justify-between items-center mb-0.5">
|
||||
<Text className="text-zinc-400 font-mono text-[10px] font-bold tracking-widest">
|
||||
{openParamDef.label}
|
||||
</Text>
|
||||
<Text
|
||||
className={`font-mono text-xs font-bold ${
|
||||
openParamDef.value !== openParamDef.default ? 'text-amber-500' : 'text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{openParamDef.display(openParamDef.value)}
|
||||
</Text>
|
||||
</View>
|
||||
<Slider
|
||||
value={openParamDef.value}
|
||||
minimumValue={openParamDef.min}
|
||||
maximumValue={openParamDef.max}
|
||||
step={openParamDef.step}
|
||||
onValueChange={openParamDef.onChange}
|
||||
onSlidingComplete={haptic}
|
||||
minimumTrackTintColor="#f59e0b"
|
||||
maximumTrackTintColor="#27272a"
|
||||
thumbTintColor="#f59e0b"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{chipsRow}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user