feat(panel): add a FAVORITED tab with a star on every recipe

This commit is contained in:
2026-09-11 09:32:21 +07:00
parent ba7e579986
commit ba971f136c
4 changed files with 120 additions and 16 deletions
+16 -1
View File
@@ -17,7 +17,7 @@ import SettingsModal from './src/components/SettingsModal';
import PhotoViewerModal from './src/components/PhotoViewerModal';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT } from './src/types';
import { getAllRecipes, saveCustomRecipe, updateCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils';
import { getAllRecipes, saveCustomRecipe, updateCustomRecipe, deleteCustomRecipe, getFavoriteIds, setFavoriteId } from './src/utils/storageUtils';
import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from './src/utils/defaultRecipes';
import { getCurrentGPS, requestLocationPermissions, reverseGeocode } from './src/utils/locationUtils';
import { resolvePhotoGPS } from './src/utils/photoGps';
@@ -78,6 +78,9 @@ export default function App() {
};
}, []);
const [recipes, setRecipes] = useState<Recipe[]>([]);
// FAVORITED: ids of the starred recipes. Metadata only — never saved into the
// session look, so RESET and applying a recipe leave it untouched.
const [favoriteIds, setFavoriteIds] = useState<string[]>([]);
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null);
// Custom recipe open in the parameter editor (the CREATE form in edit mode).
const [editingRecipe, setEditingRecipe] = useState<Recipe | null>(null);
@@ -321,6 +324,7 @@ export default function App() {
const initSession = async () => {
const list = await getAllRecipes();
setRecipes(list);
setFavoriteIds(await getFavoriteIds());
// Device/camera settings restore first: they apply under BOTH startup
// modes ('default' resets only the look, not these hardware choices).
@@ -771,6 +775,15 @@ export default function App() {
}
};
// Star/un-star a recipe: optimistic (the chip repaints on tap), persisted in
// the background. The write is the only failure worth logging — the look is
// untouched either way.
const handleToggleFavorite = (id: string) => {
const on = !favoriteIds.includes(id);
setFavoriteIds((ids) => (on ? [...ids, id] : ids.filter((x) => x !== id)));
setFavoriteId(id, on).catch((e) => console.warn('Failed to save favorite:', e));
};
const handlePickImage = async () => {
if (isProcessing) return;
@@ -1327,6 +1340,8 @@ export default function App() {
onCloseEdit={() => setEditingRecipe(null)}
onUpdateRecipe={handleUpdateRecipe}
onSelectRecipe={handleSelectRecipe}
favoriteIds={favoriteIds}
onToggleFavorite={handleToggleFavorite}
onUpdateAdjustments={handleUpdateAdjustments}
onUpdateFrame={handleUpdateFrame}
onToggleGeotag={handleToggleGeotag}
+79 -14
View File
@@ -2,7 +2,7 @@ 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 { Star, Trash2 } from 'lucide-react-native';
import { Skia } from '@shopify/react-native-skia';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter, CropRatio } from '../types';
import { FRAMES } from '../utils/frameUtils';
@@ -16,7 +16,7 @@ import RecipeCreateModal from './RecipeCreateModal';
// 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';
type TabId = 'recipes' | 'favorites' | '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 =
@@ -73,6 +73,10 @@ interface AdjustmentPanelProps {
// save-as (new name).
onUpdateRecipe: (recipe: Omit<Recipe, 'id' | 'isCustom'>) => void;
onSelectRecipe: (recipe: Recipe) => void;
// FAVORITED tab + the star trailing every recipe chip. Ids only: the star is
// metadata, so it survives RESET and never changes the current look.
favoriteIds: string[];
onToggleFavorite: (id: string) => void;
onUpdateAdjustments: (adjustments: Partial<ColorAdjustments>) => void;
onUpdateFrame: (frameId: FrameId) => void;
// FRAME tab: turn the inserted library photo 90deg (landscape <-> portrait)
@@ -116,6 +120,9 @@ interface ChipDef {
onPress: () => void;
onPressDelete?: () => void; // custom presets carry an inline trash button
trash?: boolean;
star?: boolean; // trailing star button (recipes + FAVORITED)
starred?: boolean; // filled/amber star
onPressStar?: () => void;
disabled?: boolean; // informational label, not tappable
amberValue?: boolean; // amber text driven by edited value, not selection
}
@@ -196,6 +203,8 @@ export default function AdjustmentPanel({
onCloseEdit,
onUpdateRecipe,
onSelectRecipe,
favoriteIds,
onToggleFavorite,
onUpdateAdjustments,
onUpdateFrame,
photoRotation,
@@ -277,22 +286,41 @@ export default function AdjustmentPanel({
const textTone = c.active || c.amberValue ? 'text-amber-500' : 'text-zinc-300';
return (
<View key={c.key} className="mx-1">
{c.trash ? (
{c.trash || c.star ? (
<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>
{c.star && (
<TouchableOpacity
onPress={(e) => {
e.stopPropagation();
haptic();
c.onPressStar && c.onPressStar();
}}
className="p-1.5 ml-1"
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
>
<Star
size={12}
color={c.starred ? '#f59e0b' : '#a1a1aa'}
fill={c.starred ? '#f59e0b' : 'none'}
/>
</TouchableOpacity>
)}
{c.trash && (
<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">
@@ -372,6 +400,7 @@ export default function AdjustmentPanel({
}));
const paramDefs: Record<TabId, ParamDef[]> = {
recipes: [],
favorites: [],
iq: toRows(PARAM_DEFS.iq),
wb: toRows(PARAM_DEFS.wb).map((p) =>
p.key === 'temperature'
@@ -589,6 +618,39 @@ export default function AdjustmentPanel({
);
}
case 'favorites': {
// FAVORITED: every starred recipe as a chip (star to un-star), newest
// look selectable in one tap. Ids whose recipe was deleted simply drop.
const favs = recipes.filter((r) => favoriteIds.includes(r.id));
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
>
{favs.length === 0
? renderChip({
key: 'no-favorites',
label: 'NO FAVORITES YET - TAP THE STAR ON A RECIPE',
disabled: true,
active: false,
onPress: () => {},
})
: favs.map((r) =>
renderChip({
key: r.id,
label: r.name,
active: r.id === currentRecipeId,
star: true,
starred: true,
onPress: () => onSelectRecipe(r),
onPressStar: () => onToggleFavorite(r.id),
})
)}
</ScrollView>
);
}
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
@@ -799,8 +861,11 @@ export default function AdjustmentPanel({
key: r.id,
label: r.name,
active: r.id === currentRecipeId,
star: true,
starred: favoriteIds.includes(r.id),
trash: !!r.isCustom,
onPress: () => onSelectRecipe(r),
onPressStar: () => onToggleFavorite(r.id),
onPressDelete: () => onDeleteRecipe(r.id),
})
)}
+2 -1
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
import * as Haptics from 'expo-haptics';
export type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'watermark';
export type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'frame' | 'watermark';
interface ToolRailProps {
activeTab: TabId | null;
@@ -14,6 +14,7 @@ interface ToolRailProps {
const TOOLS: { id: TabId; label: string }[] = [
{ id: 'recipes', label: 'PRESETS' },
{ id: 'favorites', label: 'FAVORITED' },
{ id: 'iq', label: 'LIGHT' },
{ id: 'wb', label: 'WB' },
{ id: 'filters', label: 'FX' },
+23
View File
@@ -3,6 +3,29 @@ import { Recipe } from '../types';
import { DEFAULT_RECIPES, DEFAULT_ADJUSTMENTS } from './defaultRecipes';
const CUSTOM_RECIPES_KEY = '@camrecipe_pro:custom_recipes';
const FAVORITES_KEY = '@camrecipe_pro:favorites';
// FAVORITED recipes: a list of recipe ids. Pure metadata — starring a recipe
// never touches the look, so RESET and re-selecting a recipe leave it alone.
export async function getFavoriteIds(): Promise<string[]> {
try {
const data = await AsyncStorage.getItem(FAVORITES_KEY);
if (!data) return [];
const parsed = JSON.parse(data);
return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : [];
} catch (error) {
console.error('Failed to load favorites:', error);
return [];
}
}
export async function setFavoriteId(id: string, on: boolean): Promise<void> {
const ids = await getFavoriteIds();
const updated = on
? [...new Set([...ids, id])]
: ids.filter((x) => x !== id);
await AsyncStorage.setItem(FAVORITES_KEY, JSON.stringify(updated));
}
export async function getCustomRecipes(): Promise<Recipe[]> {
try {