From ba971f136c8b8870a1ec9d383dec75c45dee22e5 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Fri, 11 Sep 2026 09:32:21 +0700 Subject: [PATCH] feat(panel): add a FAVORITED tab with a star on every recipe --- App.tsx | 17 +++++- src/components/AdjustmentPanel.tsx | 93 +++++++++++++++++++++++++----- src/components/ToolRail.tsx | 3 +- src/utils/storageUtils.ts | 23 ++++++++ 4 files changed, 120 insertions(+), 16 deletions(-) diff --git a/App.tsx b/App.tsx index 2c66dbf..21c23b4 100644 --- a/App.tsx +++ b/App.tsx @@ -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([]); + // 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([]); const [selectedRecipe, setSelectedRecipe] = useState(null); // Custom recipe open in the parameter editor (the CREATE form in edit mode). const [editingRecipe, setEditingRecipe] = useState(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} diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index 73bcb6f..e23e43c 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -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) => 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) => 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 ( - {c.trash ? ( + {c.trash || c.star ? ( { 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.star && ( + { + e.stopPropagation(); + haptic(); + c.onPressStar && c.onPressStar(); + }} + className="p-1.5 ml-1" + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + > + + + )} + {c.trash && ( + { + e.stopPropagation(); + haptic(); + c.onPressDelete && c.onPressDelete(); + }} + className="p-1.5 ml-1" + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + > + + + )} ) : c.disabled ? ( @@ -372,6 +400,7 @@ export default function AdjustmentPanel({ })); const paramDefs: Record = { 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 ( + + {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), + }) + )} + + ); + } + 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), }) )} diff --git a/src/components/ToolRail.tsx b/src/components/ToolRail.tsx index 3e5bc84..5c953f0 100644 --- a/src/components/ToolRail.tsx +++ b/src/components/ToolRail.tsx @@ -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' }, diff --git a/src/utils/storageUtils.ts b/src/utils/storageUtils.ts index 8ecc1ac..6b28131 100644 --- a/src/utils/storageUtils.ts +++ b/src/utils/storageUtils.ts @@ -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 { + 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 { + 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 { try {