diff --git a/.husky/pre-commit b/.husky/pre-commit
index 0a291b2..7d95356 100644
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1 +1,2 @@
+node ./scripts/actionlint.mjs .github/workflows
npm run web:build
diff --git a/app/(tabs)/tools/_layout.tsx b/app/(tabs)/tools/_layout.tsx
index 793c5b6..341d990 100644
--- a/app/(tabs)/tools/_layout.tsx
+++ b/app/(tabs)/tools/_layout.tsx
@@ -17,6 +17,9 @@ export default function ToolsLayout() {
+
+
+
);
diff --git a/app/(tabs)/tools/index.tsx b/app/(tabs)/tools/index.tsx
index bfb9261..23ea954 100644
--- a/app/(tabs)/tools/index.tsx
+++ b/app/(tabs)/tools/index.tsx
@@ -24,6 +24,14 @@ const toolCards = [
accentKey: 'toolCardFeed',
route: '/tools/feed',
},
+ {
+ id: 'recipes',
+ title: 'Recipes',
+ description: 'Save your custom foods for quick logging.',
+ icon: 'book.closed.fill',
+ accentKey: 'toolCardRecipes',
+ route: '/tools/recipes',
+ },
{
id: 'weight',
title: 'Weight',
diff --git a/app/(tabs)/tools/recipes/create.tsx b/app/(tabs)/tools/recipes/create.tsx
new file mode 100644
index 0000000..764cf09
--- /dev/null
+++ b/app/(tabs)/tools/recipes/create.tsx
@@ -0,0 +1,447 @@
+import { useRouter } from 'expo-router';
+import type { Ref } from 'react';
+import { useMemo, useRef, useState } from 'react';
+import {
+ ActivityIndicator,
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ StyleSheet,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+
+import { ThemedText } from '@/components/themed-text';
+import type { Theme } from '@/constants/theme';
+import { useAppTheme } from '@/providers/ThemePreferenceProvider';
+import { createFood } from '@/services/foods';
+import { createEmptyNutritionValues, nutritionFieldConfigs, type NutritionFieldKey, type NutritionFormValues } from './nutritionFields';
+import { validateFoodForm, type FoodFormErrors } from './validation';
+
+export default function CreateFoodScreen() {
+ const router = useRouter();
+ const { theme } = useAppTheme();
+ const styles = useMemo(() => createStyles(theme), [theme]);
+ const [brandName, setBrandName] = useState('');
+ const [description, setDescription] = useState('');
+ const [servingSize, setServingSize] = useState('');
+ const [servingsPerContainer, setServingsPerContainer] = useState('1');
+ const [submitting, setSubmitting] = useState(false);
+ const [fieldErrors, setFieldErrors] = useState({});
+ const [bannerError, setBannerError] = useState(null);
+ const [nutritionValues, setNutritionValues] = useState(createEmptyNutritionValues());
+ const scrollViewRef = useRef(null);
+ const descriptionRef = useRef(null);
+ const servingSizeRef = useRef(null);
+ const servingsPerContainerRef = useRef(null);
+ const nutritionInputRefs = useRef>({} as Record);
+
+ const handleNutritionChange = (key: NutritionFieldKey, value: string) => {
+ setNutritionValues((prev) => ({
+ ...prev,
+ [key]: value,
+ }));
+ };
+
+ const focusFirstError = (errors: FoodFormErrors) => {
+ if (errors.description && descriptionRef.current) {
+ descriptionRef.current.focus();
+ return;
+ }
+ if (errors.servingSize && servingSizeRef.current) {
+ servingSizeRef.current.focus();
+ return;
+ }
+ if (errors.servingsPerContainer && servingsPerContainerRef.current) {
+ servingsPerContainerRef.current.focus();
+ return;
+ }
+ if (errors.nutrition) {
+ for (const field of nutritionFieldConfigs) {
+ if (errors.nutrition?.[field.key]) {
+ nutritionInputRefs.current[field.key]?.focus();
+ return;
+ }
+ }
+ }
+ };
+
+ const handleSubmit = async () => {
+ setBannerError(null);
+ const { errors, isValid, servingsValue, nutritionPayload } = validateFoodForm({
+ description,
+ servingSize,
+ servingsPerContainer,
+ nutritionValues,
+ });
+
+ setFieldErrors(errors);
+
+ if (!isValid || !servingsValue || !nutritionPayload) {
+ setBannerError('Please fix the highlighted fields below.');
+ scrollViewRef.current?.scrollTo({ y: 0, animated: true });
+ focusFirstError(errors);
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ await createFood({
+ brandName: brandName.trim() || undefined,
+ description: description.trim(),
+ servingSize: servingSize.trim(),
+ servingsPerContainer: servingsValue,
+ ...nutritionPayload,
+ });
+ setFieldErrors({});
+ router.back();
+ } catch (error: any) {
+ setBannerError(error?.message ?? 'Unable to save food right now.');
+ scrollViewRef.current?.scrollTo({ y: 0, animated: true });
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+ Create Food
+
+ Add the details for this food so you can reuse it later.
+
+
+
+ {bannerError ? (
+
+ Needs attention
+ {bannerError}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ Nutrition Facts
+
+ {nutritionFieldConfigs.map((field, index) => (
+ handleNutritionChange(field.key, text)}
+ theme={theme}
+ isLast={index === nutritionFieldConfigs.length - 1}
+ inputRef={(ref) => {
+ nutritionInputRefs.current[field.key] = ref;
+ }}
+ errorText={fieldErrors.nutrition?.[field.key]}
+ />
+ ))}
+
+
+
+
+ {submitting ? (
+
+ ) : (
+ Save Food
+ )}
+
+
+
+
+ );
+}
+
+const InputField = ({
+ label,
+ value,
+ onChangeText,
+ placeholder,
+ theme,
+ optional,
+ required,
+ keyboardType,
+ inputRef,
+ errorText,
+}: {
+ label: string;
+ value: string;
+ onChangeText: (value: string) => void;
+ placeholder?: string;
+ theme: Theme;
+ optional?: boolean;
+ required?: boolean;
+ keyboardType?: 'default' | 'decimal-pad' | 'number-pad';
+ inputRef?: Ref;
+ errorText?: string;
+}) => {
+ const autoCap = keyboardType && keyboardType !== 'default' ? 'none' : 'sentences';
+ return (
+
+
+ {label}
+ {required ? (
+ Required
+ ) : optional ? (
+ Optional
+ ) : null}
+
+
+ {errorText ? {errorText} : null}
+
+ );
+};
+
+const NutritionFieldRow = ({
+ label,
+ unit,
+ value,
+ required,
+ onChangeText,
+ theme,
+ isLast,
+ inputRef,
+ errorText,
+}: {
+ label: string;
+ unit?: string;
+ value: string;
+ required?: boolean;
+ onChangeText: (val: string) => void;
+ theme: Theme;
+ isLast?: boolean;
+ inputRef?: Ref;
+ errorText?: string;
+}) => {
+ const showError = Boolean(errorText);
+ return (
+
+
+ {label}
+ {unit ? {unit} : null}
+ {showError ? (
+ {errorText}
+ ) : null}
+
+
+
+ );
+};
+
+const createStyles = (theme: Theme) =>
+ StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: theme.background,
+ },
+ flex: {
+ flex: 1,
+ },
+ content: {
+ paddingHorizontal: 20,
+ paddingBottom: 40,
+ gap: 20,
+ },
+ header: {
+ marginTop: 16,
+ gap: 6,
+ },
+ subtitle: {
+ color: theme.textSecondary,
+ },
+ formGroup: {
+ gap: 16,
+ },
+ section: {
+ gap: 8,
+ },
+ sectionLabel: {
+ fontSize: 15,
+ fontWeight: '600',
+ color: theme.text,
+ textTransform: 'uppercase',
+ letterSpacing: 0.6,
+ },
+ nutritionList: {
+ borderRadius: 16,
+ backgroundColor: theme.card,
+ overflow: 'hidden',
+ },
+ submitButton: {
+ borderRadius: 18,
+ paddingVertical: 16,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ submitButtonDisabled: {
+ opacity: 0.7,
+ },
+ submitButtonText: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ banner: {
+ borderRadius: 14,
+ borderWidth: 1,
+ padding: 12,
+ gap: 4,
+ backgroundColor: theme.card,
+ },
+ bannerTitle: {
+ fontSize: 13,
+ fontWeight: '600',
+ textTransform: 'uppercase',
+ letterSpacing: 0.5,
+ },
+ bannerMessage: {
+ fontSize: 14,
+ color: theme.text,
+ },
+ });
+
+const stylesField = StyleSheet.create({
+ field: {
+ gap: 8,
+ },
+ labelRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ labelText: {
+ fontSize: 15,
+ fontWeight: '600',
+ },
+ metaText: {
+ fontSize: 13,
+ },
+ input: {
+ borderWidth: 1,
+ borderRadius: 14,
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ fontSize: 16,
+ },
+ errorText: {
+ fontSize: 13,
+ },
+});
+
+const stylesNutrition = StyleSheet.create({
+ row: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ paddingVertical: 14,
+ },
+ labelColumn: {
+ flex: 1,
+ paddingRight: 12,
+ },
+ label: {
+ fontSize: 15,
+ fontWeight: '500',
+ },
+ unit: {
+ fontSize: 12,
+ },
+ input: {
+ minWidth: 80,
+ fontSize: 15,
+ },
+ errorText: {
+ fontSize: 12,
+ marginTop: 4,
+ },
+});
+
diff --git a/app/(tabs)/tools/recipes/edit.tsx b/app/(tabs)/tools/recipes/edit.tsx
new file mode 100644
index 0000000..dccebfb
--- /dev/null
+++ b/app/(tabs)/tools/recipes/edit.tsx
@@ -0,0 +1,540 @@
+import { useLocalSearchParams, useRouter } from 'expo-router';
+import type { Ref } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import {
+ ActivityIndicator,
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ StyleSheet,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+
+import { ThemedText } from '@/components/themed-text';
+import type { Theme } from '@/constants/theme';
+import { useAppTheme } from '@/providers/ThemePreferenceProvider';
+import { getFoodById, updateFood } from '@/services/foods';
+import {
+ createEmptyNutritionValues,
+ createNutritionValuesFromFood,
+ nutritionFieldConfigs,
+ type NutritionFieldKey,
+ type NutritionFormValues,
+} from './nutritionFields';
+import { validateFoodForm, type FoodFormErrors } from './validation';
+
+export default function EditFoodScreen() {
+ const router = useRouter();
+ const params = useLocalSearchParams<{ id?: string }>();
+ const foodId = typeof params.id === 'string' ? params.id : undefined;
+ const { theme } = useAppTheme();
+ const styles = useMemo(() => createStyles(theme), [theme]);
+ const [brandName, setBrandName] = useState('');
+ const [description, setDescription] = useState('');
+ const [servingSize, setServingSize] = useState('');
+ const [servingsPerContainer, setServingsPerContainer] = useState('1');
+ const [submitting, setSubmitting] = useState(false);
+ const [fieldErrors, setFieldErrors] = useState({});
+ const [bannerError, setBannerError] = useState(null);
+ const [initialLoading, setInitialLoading] = useState(true);
+ const [initialError, setInitialError] = useState(null);
+ const [nutritionValues, setNutritionValues] = useState(createEmptyNutritionValues());
+ const scrollViewRef = useRef(null);
+ const descriptionRef = useRef(null);
+ const servingSizeRef = useRef(null);
+ const servingsPerContainerRef = useRef(null);
+ const nutritionInputRefs = useRef>({} as Record);
+
+ const handleNutritionChange = (key: NutritionFieldKey, value: string) => {
+ setNutritionValues((prev) => ({
+ ...prev,
+ [key]: value,
+ }));
+ };
+
+ const focusFirstError = (errors: FoodFormErrors) => {
+ if (errors.description && descriptionRef.current) {
+ descriptionRef.current.focus();
+ return;
+ }
+ if (errors.servingSize && servingSizeRef.current) {
+ servingSizeRef.current.focus();
+ return;
+ }
+ if (errors.servingsPerContainer && servingsPerContainerRef.current) {
+ servingsPerContainerRef.current.focus();
+ return;
+ }
+ if (errors.nutrition) {
+ for (const field of nutritionFieldConfigs) {
+ if (errors.nutrition?.[field.key]) {
+ nutritionInputRefs.current[field.key]?.focus();
+ return;
+ }
+ }
+ }
+ };
+
+ useEffect(() => {
+ let isMounted = true;
+ async function loadFood() {
+ if (!foodId) {
+ setInitialError('Missing food identifier.');
+ setInitialLoading(false);
+ return;
+ }
+ try {
+ const food = await getFoodById(foodId);
+ if (!isMounted) return;
+ setBrandName(food.brandName ?? '');
+ setDescription(food.description);
+ setServingSize(food.servingSize);
+ setServingsPerContainer(String(food.servingsPerContainer));
+ setNutritionValues(createNutritionValuesFromFood(food));
+ setFieldErrors({});
+ setBannerError(null);
+ } catch (error: any) {
+ if (!isMounted) return;
+ setInitialError(error?.message ?? 'Unable to load this food.');
+ } finally {
+ if (isMounted) {
+ setInitialLoading(false);
+ }
+ }
+ }
+ loadFood();
+ return () => {
+ isMounted = false;
+ };
+ }, [foodId]);
+
+ const handleSubmit = async () => {
+ setBannerError(null);
+ if (!foodId) {
+ setBannerError('Missing food identifier.');
+ scrollViewRef.current?.scrollTo({ y: 0, animated: true });
+ return;
+ }
+
+ const { errors, isValid, servingsValue, nutritionPayload } = validateFoodForm({
+ description,
+ servingSize,
+ servingsPerContainer,
+ nutritionValues,
+ });
+
+ setFieldErrors(errors);
+
+ if (!isValid || !servingsValue || !nutritionPayload) {
+ setBannerError('Please fix the highlighted fields below.');
+ scrollViewRef.current?.scrollTo({ y: 0, animated: true });
+ focusFirstError(errors);
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ await updateFood({
+ id: foodId,
+ brandName: brandName.trim() || undefined,
+ description: description.trim(),
+ servingSize: servingSize.trim(),
+ servingsPerContainer: servingsValue,
+ ...nutritionPayload,
+ });
+ setFieldErrors({});
+ router.back();
+ } catch (error: any) {
+ setBannerError(error?.message ?? 'Unable to save changes right now.');
+ scrollViewRef.current?.scrollTo({ y: 0, animated: true });
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const renderContent = () => {
+ if (initialLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (initialError) {
+ return (
+
+ {initialError}
+ router.back()} style={[styles.submitButton, { backgroundColor: theme.primary }]}>
+ Go Back
+
+
+ );
+ }
+
+ return (
+ <>
+
+ Edit Food
+ Update the details for this saved food.
+
+
+ {bannerError ? (
+
+ Needs attention
+ {bannerError}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ Nutrition Facts
+
+ {nutritionFieldConfigs.map((field, index) => (
+ handleNutritionChange(field.key, text)}
+ theme={theme}
+ isLast={index === nutritionFieldConfigs.length - 1}
+ inputRef={(ref) => {
+ nutritionInputRefs.current[field.key] = ref;
+ }}
+ errorText={fieldErrors.nutrition?.[field.key]}
+ />
+ ))}
+
+
+
+
+ {submitting ? (
+
+ ) : (
+ Save Changes
+ )}
+
+ >
+ );
+ };
+
+ return (
+
+
+
+ {renderContent()}
+
+
+
+ );
+}
+
+const InputField = ({
+ label,
+ value,
+ onChangeText,
+ placeholder,
+ theme,
+ optional,
+ required,
+ keyboardType,
+ inputRef,
+ errorText,
+}: {
+ label: string;
+ value: string;
+ onChangeText: (value: string) => void;
+ placeholder?: string;
+ theme: Theme;
+ optional?: boolean;
+ required?: boolean;
+ keyboardType?: 'default' | 'decimal-pad' | 'number-pad';
+ inputRef?: Ref;
+ errorText?: string;
+}) => {
+ const autoCap = keyboardType && keyboardType !== 'default' ? 'none' : 'sentences';
+ return (
+
+
+ {label}
+ {required ? (
+ Required
+ ) : optional ? (
+ Optional
+ ) : null}
+
+
+ {errorText ? {errorText} : null}
+
+ );
+};
+
+const NutritionFieldRow = ({
+ label,
+ unit,
+ value,
+ required,
+ onChangeText,
+ theme,
+ isLast,
+ inputRef,
+ errorText,
+}: {
+ label: string;
+ unit?: string;
+ value: string;
+ required?: boolean;
+ onChangeText: (val: string) => void;
+ theme: Theme;
+ isLast?: boolean;
+ inputRef?: Ref;
+ errorText?: string;
+}) => {
+ const showError = Boolean(errorText);
+ return (
+
+
+ {label}
+ {unit ? {unit} : null}
+ {showError ? (
+ {errorText}
+ ) : null}
+
+
+
+ );
+};
+
+const createStyles = (theme: Theme) =>
+ StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: theme.background,
+ },
+ flex: {
+ flex: 1,
+ },
+ content: {
+ paddingHorizontal: 20,
+ paddingBottom: 40,
+ gap: 20,
+ },
+ loadingState: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingVertical: 60,
+ },
+ errorState: {
+ flex: 1,
+ gap: 16,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 60,
+ },
+ header: {
+ marginTop: 16,
+ gap: 6,
+ },
+ subtitle: {
+ color: theme.textSecondary,
+ },
+ formGroup: {
+ gap: 16,
+ },
+ section: {
+ gap: 8,
+ },
+ sectionLabel: {
+ fontSize: 15,
+ fontWeight: '600',
+ color: theme.text,
+ textTransform: 'uppercase',
+ letterSpacing: 0.6,
+ },
+ nutritionList: {
+ borderRadius: 16,
+ backgroundColor: theme.card,
+ overflow: 'hidden',
+ },
+ errorText: {
+ color: theme.danger,
+ textAlign: 'center',
+ },
+ submitButton: {
+ borderRadius: 18,
+ paddingVertical: 16,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ submitButtonDisabled: {
+ opacity: 0.7,
+ },
+ submitButtonText: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ banner: {
+ borderRadius: 14,
+ borderWidth: 1,
+ padding: 12,
+ gap: 4,
+ backgroundColor: theme.card,
+ },
+ bannerTitle: {
+ fontSize: 13,
+ fontWeight: '600',
+ textTransform: 'uppercase',
+ letterSpacing: 0.5,
+ },
+ bannerMessage: {
+ fontSize: 14,
+ color: theme.text,
+ textAlign: 'left',
+ },
+ });
+
+const stylesField = StyleSheet.create({
+ field: {
+ gap: 8,
+ },
+ labelRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ labelText: {
+ fontSize: 15,
+ fontWeight: '600',
+ },
+ metaText: {
+ fontSize: 13,
+ },
+ input: {
+ borderWidth: 1,
+ borderRadius: 14,
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ fontSize: 16,
+ },
+ errorText: {
+ fontSize: 13,
+ },
+});
+
+const stylesNutrition = StyleSheet.create({
+ row: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ paddingVertical: 14,
+ },
+ labelColumn: {
+ flex: 1,
+ paddingRight: 12,
+ },
+ label: {
+ fontSize: 15,
+ fontWeight: '500',
+ },
+ unit: {
+ fontSize: 12,
+ },
+ input: {
+ minWidth: 80,
+ fontSize: 15,
+ },
+ errorText: {
+ fontSize: 12,
+ marginTop: 4,
+ },
+});
+
diff --git a/app/(tabs)/tools/recipes/index.tsx b/app/(tabs)/tools/recipes/index.tsx
new file mode 100644
index 0000000..0a38d9a
--- /dev/null
+++ b/app/(tabs)/tools/recipes/index.tsx
@@ -0,0 +1,281 @@
+import { useFocusEffect, useRouter } from 'expo-router';
+import { useCallback, useMemo, useState } from 'react';
+import {
+ ActivityIndicator,
+ FlatList,
+ StyleSheet,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { ThemedText } from '@/components/themed-text';
+import { ThemedView } from '@/components/themed-view';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+import type { Theme } from '@/constants/theme';
+import { useAppTheme } from '@/providers/ThemePreferenceProvider';
+import { listFoods } from '@/services/foods';
+import type { SavedFood } from '@/types';
+
+type RecipesStyles = ReturnType;
+
+export default function RecipesScreen() {
+ const router = useRouter();
+ const { theme } = useAppTheme();
+ const insets = useSafeAreaInsets();
+ const styles = useMemo(() => createStyles(theme), [theme]);
+ const [foods, setFoods] = useState([]);
+ const [foodsLoading, setFoodsLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+ const [foodsError, setFoodsError] = useState(null);
+ const [search, setSearch] = useState('');
+
+ const loadFoods = useCallback(
+ async (options?: { silent?: boolean }) => {
+ if (!options?.silent) {
+ setFoodsLoading(true);
+ }
+ setFoodsError(null);
+ try {
+ const data = await listFoods();
+ setFoods(data);
+ } catch (error: any) {
+ setFoodsError(error?.message ?? 'Unable to load foods.');
+ } finally {
+ if (!options?.silent) {
+ setFoodsLoading(false);
+ }
+ }
+ },
+ []
+ );
+
+ useFocusEffect(
+ useCallback(() => {
+ loadFoods();
+ }, [loadFoods])
+ );
+
+ const handleRefresh = useCallback(async () => {
+ setRefreshing(true);
+ await loadFoods({ silent: true });
+ setRefreshing(false);
+ }, [loadFoods]);
+
+ const filteredFoods = useMemo(() => {
+ const query = search.trim().toLowerCase();
+ if (!query) return foods;
+ return foods.filter((food) => {
+ const haystack = `${food.description} ${food.brandName ?? ''} ${food.servingSize}`.toLowerCase();
+ return haystack.includes(query);
+ });
+ }, [foods, search]);
+
+ const renderEmptyState = () => {
+ if (foodsLoading) {
+ return ;
+ }
+ if (foodsError) {
+ return (
+
+ {foodsError}
+ loadFoods()}>
+ Try again
+
+
+ );
+ }
+ return (
+
+ Nothing here yet. Create a food to save your favorite recipes.
+
+ );
+ };
+
+ return (
+
+
+
+ Recipes
+
+ Save custom foods that you prepare often and reuse them quickly.
+
+
+
+
+
+
+
+
+ item.id}
+ renderItem={({ item }) => (
+ router.push({ pathname: '/tools/recipes/edit', params: { id: item.id } })}
+ />
+ )}
+ contentContainerStyle={[
+ styles.listContent,
+ { paddingBottom: 120 + insets.bottom },
+ filteredFoods.length === 0 && styles.listContentCentered,
+ ]}
+ ItemSeparatorComponent={() => }
+ ListEmptyComponent={renderEmptyState}
+ refreshing={refreshing}
+ onRefresh={handleRefresh}
+ />
+
+
+
+ router.push('/tools/recipes/create')}
+ >
+ Create a Food
+
+
+
+ );
+}
+
+const FoodRow = ({
+ food,
+ theme,
+ styles,
+ onPress,
+}: {
+ food: SavedFood;
+ theme: Theme;
+ styles: RecipesStyles;
+ onPress: () => void;
+}) => {
+ return (
+
+
+
+ {food.description}
+ {food.brandName ? (
+ {food.brandName}
+ ) : null}
+
+ {food.servingSize} • {food.servingsPerContainer} servings • {food.calories} cal
+
+
+
+
+ );
+};
+
+const createStyles = (theme: Theme) =>
+ StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: theme.background,
+ },
+ container: {
+ flex: 1,
+ paddingHorizontal: 20,
+ paddingTop: 24,
+ paddingBottom: 32,
+ gap: 16,
+ },
+ header: {
+ gap: 6,
+ },
+ subtitle: {
+ color: theme.textSecondary,
+ },
+ searchBar: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ borderWidth: 1,
+ borderRadius: 14,
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ gap: 8,
+ },
+ searchInput: {
+ flex: 1,
+ color: theme.text,
+ fontSize: 16,
+ },
+ listContent: {
+ paddingBottom: 0,
+ },
+ listContentCentered: {
+ flexGrow: 1,
+ justifyContent: 'center',
+ },
+ separator: {
+ height: 12,
+ },
+ foodRow: {
+ borderRadius: 16,
+ padding: 16,
+ },
+ foodContent: {
+ gap: 4,
+ },
+ foodTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ foodSubtitle: {
+ color: theme.textSecondary,
+ fontSize: 14,
+ },
+ foodMeta: {
+ color: theme.textTertiary,
+ fontSize: 13,
+ },
+ loader: {
+ paddingTop: 24,
+ },
+ errorBox: {
+ alignItems: 'center',
+ gap: 8,
+ },
+ errorText: {
+ textAlign: 'center',
+ color: theme.danger,
+ },
+ retryText: {
+ color: theme.primary,
+ fontWeight: '600',
+ },
+ emptyText: {
+ color: theme.textSecondary,
+ textAlign: 'center',
+ },
+ createButtonWrapper: {
+ position: 'absolute',
+ left: 20,
+ right: 20,
+ bottom: 24,
+ },
+ createButton: {
+ borderRadius: 18,
+ paddingVertical: 16,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ createButtonText: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ });
+
diff --git a/app/(tabs)/tools/recipes/nutritionFields.ts b/app/(tabs)/tools/recipes/nutritionFields.ts
new file mode 100644
index 0000000..1360006
--- /dev/null
+++ b/app/(tabs)/tools/recipes/nutritionFields.ts
@@ -0,0 +1,111 @@
+import type { SavedFood, SavedFoodInput } from '@/types';
+
+export type NutritionFieldKey =
+ | 'calories'
+ | 'totalFat'
+ | 'saturatedFat'
+ | 'polyunsaturatedFat'
+ | 'monounsaturatedFat'
+ | 'transFat'
+ | 'cholesterol'
+ | 'sodium'
+ | 'potassium'
+ | 'totalCarbohydrates'
+ | 'dietaryFiber'
+ | 'sugars'
+ | 'addedSugars'
+ | 'sugarAlcohols'
+ | 'protein';
+
+export type NutritionFieldConfig = {
+ key: NutritionFieldKey;
+ label: string;
+ unit?: string;
+ parser: 'int' | 'float';
+ required?: boolean;
+};
+
+const nutritionFieldConfigsConst = [
+ { key: 'calories', label: 'Calories', unit: '', required: true, parser: 'int' },
+ { key: 'totalFat', label: 'Total Fat', unit: 'g', parser: 'float' },
+ { key: 'saturatedFat', label: 'Saturated Fat', unit: 'g', parser: 'float' },
+ { key: 'polyunsaturatedFat', label: 'Polyunsaturated Fat', unit: 'g', parser: 'float' },
+ { key: 'monounsaturatedFat', label: 'Monounsaturated', unit: 'g', parser: 'float' },
+ { key: 'transFat', label: 'Trans Fat', unit: 'g', parser: 'float' },
+ { key: 'cholesterol', label: 'Cholesterol', unit: 'mg', parser: 'int' },
+ { key: 'sodium', label: 'Sodium', unit: 'mg', parser: 'int' },
+ { key: 'potassium', label: 'Potassium', unit: 'mg', parser: 'int' },
+ { key: 'totalCarbohydrates', label: 'Total Carbohydrates', unit: 'g', parser: 'float' },
+ { key: 'dietaryFiber', label: 'Dietary Fiber', unit: 'g', parser: 'float' },
+ { key: 'sugars', label: 'Sugars', unit: 'g', parser: 'float' },
+ { key: 'addedSugars', label: 'Added Sugars', unit: 'g', parser: 'float' },
+ { key: 'sugarAlcohols', label: 'Sugar Alcohols', unit: 'g', parser: 'float' },
+ { key: 'protein', label: 'Protein', unit: 'g', parser: 'float' },
+] as const;
+
+export const nutritionFieldConfigs: readonly NutritionFieldConfig[] = nutritionFieldConfigsConst;
+
+export type NutritionFormValues = Record;
+
+export const createEmptyNutritionValues = (): NutritionFormValues =>
+ nutritionFieldConfigs.reduce(
+ (acc, field) => ({
+ ...acc,
+ [field.key]: '',
+ }),
+ {} as NutritionFormValues
+ );
+
+const formatNumber = (value?: number) => {
+ if (value === undefined || value === null) return '';
+ if (Number.isInteger(value)) return value.toString();
+ return value.toString();
+};
+
+export const createNutritionValuesFromFood = (food?: SavedFood): NutritionFormValues => {
+ const base = createEmptyNutritionValues();
+ if (!food) return base;
+
+ return {
+ ...base,
+ calories: formatNumber(food.calories),
+ totalFat: formatNumber(food.totalFat),
+ saturatedFat: formatNumber(food.saturatedFat),
+ polyunsaturatedFat: formatNumber(food.polyunsaturatedFat),
+ monounsaturatedFat: formatNumber(food.monounsaturatedFat),
+ transFat: formatNumber(food.transFat),
+ cholesterol: formatNumber(food.cholesterol),
+ sodium: formatNumber(food.sodium),
+ potassium: formatNumber(food.potassium),
+ totalCarbohydrates: formatNumber(food.totalCarbohydrates),
+ dietaryFiber: formatNumber(food.dietaryFiber),
+ sugars: formatNumber(food.sugars),
+ addedSugars: formatNumber(food.addedSugars),
+ sugarAlcohols: formatNumber(food.sugarAlcohols),
+ protein: formatNumber(food.protein),
+ };
+};
+
+export type NutritionPayload = Partial>;
+
+export const buildNutritionPayload = (values: NutritionFormValues): NutritionPayload => {
+ const payload: NutritionPayload = {};
+
+ nutritionFieldConfigs.forEach((field) => {
+ const rawValue = values[field.key]?.trim();
+ if (!rawValue) return;
+ const parsed =
+ field.parser === 'int' ? parseInt(rawValue, 10) : parseFloat(rawValue);
+ if (Number.isFinite(parsed)) {
+ payload[field.key] = parsed;
+ }
+ });
+
+ return payload;
+};
+
+export const nutritionPayloadHasCalories = (
+ payload: NutritionPayload
+): payload is NutritionPayload & Required> =>
+ typeof payload.calories === 'number' && Number.isFinite(payload.calories);
+
diff --git a/app/(tabs)/tools/recipes/validation.ts b/app/(tabs)/tools/recipes/validation.ts
new file mode 100644
index 0000000..1905865
--- /dev/null
+++ b/app/(tabs)/tools/recipes/validation.ts
@@ -0,0 +1,81 @@
+import type { SavedFoodInput } from '@/types';
+
+import type { NutritionFieldKey, NutritionFormValues, NutritionPayload } from './nutritionFields';
+import { buildNutritionPayload, nutritionPayloadHasCalories } from './nutritionFields';
+
+export type FoodFormErrors = {
+ description?: string;
+ servingSize?: string;
+ servingsPerContainer?: string;
+ nutrition?: Partial>;
+};
+
+export type ValidateFoodFormArgs = {
+ description: string;
+ servingSize: string;
+ servingsPerContainer: string;
+ nutritionValues: NutritionFormValues;
+};
+
+export type NutritionPayloadWithCalories = NutritionPayload & Required>;
+
+export type FoodFormValidationResult = {
+ errors: FoodFormErrors;
+ isValid: boolean;
+ servingsValue?: number;
+ nutritionPayload?: NutritionPayloadWithCalories;
+};
+
+const hasNutritionErrors = (nutritionErrors?: Partial>) =>
+ Boolean(nutritionErrors && Object.keys(nutritionErrors).length > 0);
+
+export const validateFoodForm = ({
+ description,
+ servingSize,
+ servingsPerContainer,
+ nutritionValues,
+}: ValidateFoodFormArgs): FoodFormValidationResult => {
+ const errors: FoodFormErrors = {};
+ let servingsValue: number | undefined;
+
+ if (!description.trim()) {
+ errors.description = 'Description is required.';
+ }
+
+ if (!servingSize.trim()) {
+ errors.servingSize = 'Serving size is required.';
+ }
+
+ const parsedServings = Number(servingsPerContainer);
+ if (!Number.isFinite(parsedServings) || parsedServings <= 0) {
+ errors.servingsPerContainer = 'Servings per container must be greater than 0.';
+ } else {
+ servingsValue = parsedServings;
+ }
+
+ const nutritionPayload = buildNutritionPayload(nutritionValues);
+ let ensuredNutritionPayload: NutritionPayloadWithCalories | undefined;
+ if (!nutritionPayloadHasCalories(nutritionPayload)) {
+ errors.nutrition = {
+ calories: 'Calories are required.',
+ };
+ } else {
+ ensuredNutritionPayload = nutritionPayload;
+ }
+
+ const isValid =
+ !errors.description &&
+ !errors.servingSize &&
+ !errors.servingsPerContainer &&
+ !hasNutritionErrors(errors.nutrition) &&
+ Boolean(ensuredNutritionPayload);
+
+ return {
+ errors,
+ isValid,
+ servingsValue,
+ nutritionPayload: isValid ? ensuredNutritionPayload : undefined,
+ };
+};
+
+
diff --git a/app/food-search.tsx b/app/food-search.tsx
index e692d6c..2476813 100644
--- a/app/food-search.tsx
+++ b/app/food-search.tsx
@@ -1,16 +1,16 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { Audio } from 'expo-av';
import { useRouter } from 'expo-router';
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
- ActivityIndicator,
- Alert,
- FlatList,
- Modal,
- StyleSheet,
- TextInput,
- TouchableOpacity,
- View,
+ ActivityIndicator,
+ Alert,
+ FlatList,
+ Modal,
+ StyleSheet,
+ TextInput,
+ TouchableOpacity,
+ View,
} from 'react-native';
import { ThemedText } from '@/components/themed-text';
@@ -19,6 +19,7 @@ import type { Theme } from '@/constants/theme';
import { useAppTheme } from '@/providers/ThemePreferenceProvider';
import { getOpenAiApiKey, transcribeAudioFile } from '@/services/ai';
import { searchFoods } from '@/services/foodSearch';
+import { listFoods, savedFoodToFoodItem } from '@/services/foods';
import { createMeal, mapFoodToMealInput } from '@/services/meals';
import type { FoodItem, MealType } from '@/types';
@@ -33,6 +34,8 @@ const isAbortError = (error: unknown): boolean =>
export default function FoodSearch() {
const [searchQuery, setSearchQuery] = useState('');
const [results, setResults] = useState([]);
+ const [savedResults, setSavedResults] = useState([]);
+ const [savedResultsLoading, setSavedResultsLoading] = useState(false);
const [searching, setSearching] = useState(false);
const [recording, setRecording] = useState(null);
const [isRecording, setIsRecording] = useState(false);
@@ -50,6 +53,7 @@ export default function FoodSearch() {
const hasSearchQuery = trimmedSearchQuery.length > 0;
const searchAbortController = useRef(null);
const latestSearchRequestId = useRef(0);
+ const savedSearchRequestId = useRef(0);
const searchFood = async (query: string, page = 1, append = false) => {
const normalizedQuery = query.trim();
@@ -202,9 +206,10 @@ export default function FoodSearch() {
};
const dynamicStyles = createStyles(theme);
+ const combinedResults = useMemo(() => [...savedResults, ...results], [savedResults, results]);
const filteredResults = useMemo(
- () => (onlyBranded ? results.filter((item) => Boolean(item.brand)) : results),
- [results, onlyBranded]
+ () => (onlyBranded ? combinedResults.filter((item) => Boolean(item.brand)) : combinedResults),
+ [combinedResults, onlyBranded]
);
const renderFoodItem = ({ item }: { item: FoodItem }) => (
@@ -217,6 +222,11 @@ export default function FoodSearch() {
)}
+ {item.id.startsWith('saved_food_') && (
+
+ Yours
+
+ )}
{item.calories} cal, {item.serving}
@@ -236,7 +246,10 @@ export default function FoodSearch() {
useEffect(() => {
if (!trimmedSearchQuery) {
+ savedSearchRequestId.current += 1;
setResults([]);
+ setSavedResults([]);
+ setSavedResultsLoading(false);
setSearching(false);
setHasMore(false);
setCurrentPage(1);
@@ -250,6 +263,40 @@ export default function FoodSearch() {
return () => clearTimeout(timeout);
}, [trimmedSearchQuery]);
+ const searchSavedFoods = useCallback(
+ async (query: string) => {
+ const normalizedQuery = query.trim();
+ if (!normalizedQuery) {
+ savedSearchRequestId.current += 1;
+ setSavedResults([]);
+ setSavedResultsLoading(false);
+ return;
+ }
+
+ const requestId = savedSearchRequestId.current + 1;
+ savedSearchRequestId.current = requestId;
+ setSavedResultsLoading(true);
+ try {
+ const foods = await listFoods(normalizedQuery);
+ if (savedSearchRequestId.current === requestId) {
+ setSavedResults(foods.map(savedFoodToFoodItem));
+ }
+ } catch (error) {
+ console.error('Saved foods search error:', error);
+ } finally {
+ if (savedSearchRequestId.current === requestId) {
+ setSavedResultsLoading(false);
+ }
+ }
+ },
+ []
+ );
+
+ useEffect(() => {
+ if (!trimmedSearchQuery) return;
+ searchSavedFoods(trimmedSearchQuery);
+ }, [searchSavedFoods, trimmedSearchQuery]);
+
useEffect(() => {
return () => {
if (searchAbortController.current) {
@@ -331,6 +378,12 @@ export default function FoodSearch() {
+ {savedResultsLoading && (
+
+
+ Loading your foods…
+
+ )}
StyleSheet.create({
justifyContent: 'space-between',
alignItems: 'center',
},
+ savedLoadingRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ paddingHorizontal: 20,
+ paddingBottom: 8,
+ },
+ savedLoadingText: {
+ fontSize: 12,
+ color: theme.textSecondary,
+ },
resultsTitle: {
fontSize: 18,
fontWeight: '600',
@@ -508,6 +572,17 @@ const createStyles = (theme: Theme) => StyleSheet.create({
borderRadius: 999,
backgroundColor: 'rgba(10,132,255,0.15)',
},
+ savedBadge: {
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 999,
+ backgroundColor: theme.primary,
+ },
+ savedBadgeText: {
+ fontSize: 11,
+ fontWeight: '600',
+ color: theme.onPrimary,
+ },
foodServing: {
fontSize: 13,
color: theme.textSecondary,
diff --git a/components/ui/icon-symbol.tsx b/components/ui/icon-symbol.tsx
index 7290bd7..5f74e03 100644
--- a/components/ui/icon-symbol.tsx
+++ b/components/ui/icon-symbol.tsx
@@ -39,6 +39,7 @@ const MAPPING: IconMapping = {
'mic.fill': 'mic',
'paintbrush.fill': 'brush',
'heart.fill': 'favorite',
+ 'book.closed.fill': 'menu-book',
'checkmark.circle.fill': 'check-circle',
'plus.circle.fill': 'add-circle',
'arrow.clockwise.circle': 'refresh',
diff --git a/constants/theme.ts b/constants/theme.ts
index 4cc42d4..685fba5 100644
--- a/constants/theme.ts
+++ b/constants/theme.ts
@@ -49,6 +49,7 @@ const fitblue = {
toolCardBloodPressure: '#FF375F',
toolCardWeight: '#5AC8FA',
toolCardFeed: '#AF52DE',
+ toolCardRecipes: '#00C6AE',
metricProtein: '#FFB347',
metricCarbs: '#34C759',
metricFat: '#FF6B6B',
@@ -93,6 +94,7 @@ const fitblue = {
toolCardBloodPressure: '#FF375F',
toolCardWeight: '#5AC8FA',
toolCardFeed: '#BF5AF2',
+ toolCardRecipes: '#00C6AE',
metricProtein: '#FFB347',
metricCarbs: '#34C759',
metricFat: '#FF6B6B',
@@ -140,6 +142,7 @@ const healthyGreen = {
toolCardBloodPressure: '#FF6B6B',
toolCardWeight: '#21B573',
toolCardFeed: '#2FB177',
+ toolCardRecipes: '#20B486',
metricProtein: '#F5B94C',
metricCarbs: '#25B874',
metricFat: '#FF6B6B',
@@ -184,6 +187,7 @@ const healthyGreen = {
toolCardBloodPressure: '#FF6B6B',
toolCardWeight: '#20D18A',
toolCardFeed: '#25D48C',
+ toolCardRecipes: '#20B486',
metricProtein: '#F5B94C',
metricCarbs: '#32E28F',
metricFat: '#FF6B6B',
@@ -231,6 +235,7 @@ const modern = {
toolCardBloodPressure: '#F43F5E',
toolCardWeight: '#0EA5E9',
toolCardFeed: '#F472B6',
+ toolCardRecipes: '#0EA5E9',
metricProtein: '#FACC15',
metricCarbs: '#22C55E',
metricFat: '#F43F5E',
@@ -275,6 +280,7 @@ const modern = {
toolCardBloodPressure: '#FB7185',
toolCardWeight: '#38BDF8',
toolCardFeed: '#F783AC',
+ toolCardRecipes: '#38BDF8',
metricProtein: '#FACC15',
metricCarbs: '#22C55E',
metricFat: '#FB7185',
@@ -322,6 +328,7 @@ const sunsetPulse = {
toolCardBloodPressure: '#FF6B88',
toolCardWeight: '#FFB84D',
toolCardFeed: '#9C1DE7',
+ toolCardRecipes: '#FFB84D',
metricProtein: '#FFB84D',
metricCarbs: '#4CC9F0',
metricFat: '#FF6B88',
@@ -366,6 +373,7 @@ const sunsetPulse = {
toolCardBloodPressure: '#FF99B0',
toolCardWeight: '#FFCE73',
toolCardFeed: '#C77DFF',
+ toolCardRecipes: '#FFCE73',
metricProtein: '#FFCE73',
metricCarbs: '#4CC9F0',
metricFat: '#FF99B0',
@@ -413,6 +421,7 @@ const auroraGlow = {
toolCardBloodPressure: '#FF5F79',
toolCardWeight: '#2FD5A7',
toolCardFeed: '#C084FC',
+ toolCardRecipes: '#2FD5A7',
metricProtein: '#FFB347',
metricCarbs: '#2FD5A7',
metricFat: '#FF5F79',
@@ -457,6 +466,7 @@ const auroraGlow = {
toolCardBloodPressure: '#FF9BD8',
toolCardWeight: '#31EDB9',
toolCardFeed: '#D8B4FE',
+ toolCardRecipes: '#31EDB9',
metricProtein: '#FFCB6B',
metricCarbs: '#31EDB9',
metricFat: '#FF9BD8',
@@ -504,6 +514,7 @@ const nordicFrost = {
toolCardBloodPressure: '#4C9FE5',
toolCardWeight: '#7ADCC5',
toolCardFeed: '#6C7C99',
+ toolCardRecipes: '#F2B880',
metricProtein: '#F2B880',
metricCarbs: '#7ADCC5',
metricFat: '#F28B82',
@@ -548,6 +559,7 @@ const nordicFrost = {
toolCardBloodPressure: '#65B8FF',
toolCardWeight: '#7ADCC5',
toolCardFeed: '#8EA2C8',
+ toolCardRecipes: '#F2B880',
metricProtein: '#F2B880',
metricCarbs: '#7ADCC5',
metricFat: '#F28B82',
diff --git a/package-lock.json b/package-lock.json
index 6aa00a3..d87d1b1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "magicmeal",
"version": "1.0.0",
"hasInstallScript": true,
+ "license": "ISC",
"dependencies": {
"@ai-sdk/openai": "^2.0.73",
"@expo/vector-icons": "^15.0.3",
@@ -68,6 +69,7 @@
"@semantic-release/npm": "^13.1.2",
"@semantic-release/release-notes-generator": "^14.1.0",
"@types/react": "~19.1.0",
+ "actionlint": "^2.0.6",
"commitizen": "^4.3.1",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^9.25.0",
@@ -5956,6 +5958,13 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/actionlint": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/actionlint/-/actionlint-2.0.6.tgz",
+ "integrity": "sha512-tNx8f48yJNSLXTIygGntu5dSZlIZblDt8sR7pIs7EEmmb2PkEF87d+3UKZV2GUgCuN9Awj7k4ZPrwdAxFWEqgw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
diff --git a/package.json b/package.json
index 8b19a3c..3a0ad42 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
+ "biz:reddit-scout": "node ./biz/reddit-scout.mjs",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
@@ -74,6 +75,7 @@
"@semantic-release/npm": "^13.1.2",
"@semantic-release/release-notes-generator": "^14.1.0",
"@types/react": "~19.1.0",
+ "actionlint": "^2.0.6",
"commitizen": "^4.3.1",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^9.25.0",
@@ -87,5 +89,18 @@
"commitizen": {
"path": "cz-conventional-changelog"
}
- }
+ },
+ "description": "AI-powered 💪 calorie tracker 🥗 for food, weight ⚖️, blood pressure ❤️, and recipe tracking 🍳 — built with React Native + Expo. Track your meals effortlessly with barcode scanning and AI photo recognition.",
+ "directories": {
+ "lib": "lib"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/phishy/magicmeal.git"
+ },
+ "author": "",
+ "bugs": {
+ "url": "https://github.com/phishy/magicmeal/issues"
+ },
+ "homepage": "https://github.com/phishy/magicmeal#readme"
}
diff --git a/scripts/actionlint.mjs b/scripts/actionlint.mjs
new file mode 100644
index 0000000..83798f2
--- /dev/null
+++ b/scripts/actionlint.mjs
@@ -0,0 +1,94 @@
+#!/usr/bin/env node
+
+import { createLinter } from "actionlint";
+import { readFile, readdir, stat } from "node:fs/promises";
+import path from "node:path";
+import process from "node:process";
+
+const WORKFLOW_EXTENSIONS = new Set([".yml", ".yaml"]);
+
+const targets = process.argv.slice(2);
+const searchRoots = targets.length > 0 ? targets : [".github/workflows"];
+
+/**
+ * Recursively collect workflow files under provided roots.
+ * @param {string[]} roots
+ * @returns {Promise}
+ */
+async function collectWorkflowFiles(roots) {
+ const files = [];
+
+ for (const root of roots) {
+ const absolute = path.resolve(process.cwd(), root);
+ try {
+ const info = await stat(absolute);
+ if (info.isDirectory()) {
+ files.push(...(await walkDirectory(absolute)));
+ } else if (info.isFile() && isWorkflowFile(absolute)) {
+ files.push(absolute);
+ }
+ } catch (error) {
+ console.warn(`Skipping ${root}: ${error.message}`);
+ }
+ }
+
+ return files;
+}
+
+async function walkDirectory(directory) {
+ const entries = await readdir(directory, { withFileTypes: true });
+ const files = [];
+
+ for (const entry of entries) {
+ const entryPath = path.join(directory, entry.name);
+ if (entry.isDirectory()) {
+ files.push(...(await walkDirectory(entryPath)));
+ } else if (entry.isFile() && isWorkflowFile(entryPath)) {
+ files.push(entryPath);
+ }
+ }
+
+ return files;
+}
+
+function isWorkflowFile(filePath) {
+ return WORKFLOW_EXTENSIONS.has(path.extname(filePath).toLowerCase());
+}
+
+/**
+ * Run actionlint against the selected files.
+ */
+async function main() {
+ const files = await collectWorkflowFiles(searchRoots);
+ if (files.length === 0) {
+ console.log("actionlint: no workflow files to lint");
+ return;
+ }
+
+ const linter = await createLinter();
+ let hasErrors = false;
+
+ for (const file of files) {
+ const contents = await readFile(file, "utf8");
+ const results = linter(contents, path.relative(process.cwd(), file));
+
+ for (const issue of results) {
+ hasErrors = true;
+ console.error(
+ `${issue.file}:${issue.line}:${issue.column} ${issue.message} (${issue.kind})`
+ );
+ }
+ }
+
+ if (hasErrors) {
+ process.exitCode = 1;
+ } else {
+ console.log(`actionlint: checked ${files.length} file(s)`);
+ }
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
+
diff --git a/services/foodSearch/index.ts b/services/foodSearch/index.ts
index 017a0d6..cb59799 100644
--- a/services/foodSearch/index.ts
+++ b/services/foodSearch/index.ts
@@ -1,3 +1,4 @@
+import { getDeveloperSettingsSnapshot } from '@/lib/developerSettingsStore';
import { aiFoodSearchAdapter } from '@/services/foodSearch/adapters/aiFoodSearchAdapter';
import { openFoodFactsAdapter } from '@/services/foodSearch/adapters/openFoodFactsAdapter';
import type {
@@ -6,7 +7,6 @@ import type {
FoodSearchRequest,
FoodSearchResult,
} from '@/types';
-import { getDeveloperSettingsSnapshot } from '@/lib/developerSettingsStore';
const ADAPTERS: Record = {
[openFoodFactsAdapter.id]: openFoodFactsAdapter,
diff --git a/services/foods.ts b/services/foods.ts
new file mode 100644
index 0000000..59374bf
--- /dev/null
+++ b/services/foods.ts
@@ -0,0 +1,147 @@
+import { supabase } from '@/lib/supabase';
+import { getProfileIdOrThrow } from '@/services/helpers';
+import type {
+ FoodItem,
+ SavedFood,
+ SavedFoodInput,
+ SavedFoodRecord,
+ SavedFoodUpdateInput,
+} from '@/types';
+
+const TABLE = 'foods';
+
+const toNumber = (value: number | string | null | undefined): number | undefined => {
+ if (value === null || value === undefined) return undefined;
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : undefined;
+};
+
+const mapRecord = (record: SavedFoodRecord): SavedFood => ({
+ id: record.id,
+ profileId: record.profile_id,
+ brandName: record.brand_name ?? undefined,
+ description: record.description,
+ servingSize: record.serving_size,
+ servingsPerContainer: Number(record.servings_per_container),
+ calories: Number(record.calories) || 0,
+ totalFat: toNumber(record.total_fat),
+ saturatedFat: toNumber(record.saturated_fat),
+ polyunsaturatedFat: toNumber(record.polyunsaturated_fat),
+ monounsaturatedFat: toNumber(record.monounsaturated_fat),
+ transFat: toNumber(record.trans_fat),
+ cholesterol: toNumber(record.cholesterol),
+ sodium: toNumber(record.sodium),
+ potassium: toNumber(record.potassium),
+ totalCarbohydrates: toNumber(record.total_carbohydrates),
+ dietaryFiber: toNumber(record.dietary_fiber),
+ sugars: toNumber(record.sugars),
+ addedSugars: toNumber(record.added_sugars),
+ sugarAlcohols: toNumber(record.sugar_alcohols),
+ protein: toNumber(record.protein),
+ createdAt: record.created_at,
+ updatedAt: record.updated_at,
+});
+
+const buildNutritionPayload = (input: SavedFoodInput | SavedFoodUpdateInput) => ({
+ calories: input.calories,
+ total_fat: input.totalFat ?? null,
+ saturated_fat: input.saturatedFat ?? null,
+ polyunsaturated_fat: input.polyunsaturatedFat ?? null,
+ monounsaturated_fat: input.monounsaturatedFat ?? null,
+ trans_fat: input.transFat ?? null,
+ cholesterol: input.cholesterol ?? null,
+ sodium: input.sodium ?? null,
+ potassium: input.potassium ?? null,
+ total_carbohydrates: input.totalCarbohydrates ?? null,
+ dietary_fiber: input.dietaryFiber ?? null,
+ sugars: input.sugars ?? null,
+ added_sugars: input.addedSugars ?? null,
+ sugar_alcohols: input.sugarAlcohols ?? null,
+ protein: input.protein ?? null,
+});
+
+export async function listFoods(search?: string): Promise {
+ const profileId = await getProfileIdOrThrow();
+ let query = supabase
+ .from(TABLE)
+ .select('*')
+ .eq('profile_id', profileId)
+ .order('description', { ascending: true });
+
+ if (search?.trim()) {
+ query = query.ilike('description', `%${search.trim()}%`);
+ }
+
+ const { data, error } = await query;
+ if (error) throw error;
+
+ return data.map(mapRecord);
+}
+
+export async function createFood(input: SavedFoodInput): Promise {
+ const profileId = await getProfileIdOrThrow();
+ const payload = {
+ profile_id: profileId,
+ brand_name: input.brandName?.trim() || null,
+ description: input.description.trim(),
+ serving_size: input.servingSize.trim(),
+ servings_per_container: input.servingsPerContainer,
+ ...buildNutritionPayload(input),
+ };
+
+ const { data, error } = await supabase.from(TABLE).insert(payload).select('*').single();
+ if (error) throw error;
+
+ return mapRecord(data);
+}
+
+export async function getFoodById(id: string): Promise {
+ const profileId = await getProfileIdOrThrow();
+ const { data, error } = await supabase
+ .from(TABLE)
+ .select('*')
+ .eq('id', id)
+ .eq('profile_id', profileId)
+ .single();
+
+ if (error) throw error;
+ return mapRecord(data);
+}
+
+export async function updateFood(input: SavedFoodUpdateInput): Promise {
+ const profileId = await getProfileIdOrThrow();
+ const payload = {
+ brand_name: input.brandName?.trim() || null,
+ description: input.description.trim(),
+ serving_size: input.servingSize.trim(),
+ servings_per_container: input.servingsPerContainer,
+ updated_at: new Date().toISOString(),
+ ...buildNutritionPayload(input),
+ };
+
+ const { data, error } = await supabase
+ .from(TABLE)
+ .update(payload)
+ .eq('id', input.id)
+ .eq('profile_id', profileId)
+ .select('*')
+ .single();
+
+ if (error) throw error;
+
+ return mapRecord(data);
+}
+
+export function savedFoodToFoodItem(savedFood: SavedFood): FoodItem {
+ return {
+ id: `saved_food_${savedFood.id}`,
+ name: savedFood.description,
+ brand: savedFood.brandName,
+ calories: savedFood.calories ?? 0,
+ protein: savedFood.protein ?? 0,
+ carbs: savedFood.totalCarbohydrates ?? 0,
+ fat: savedFood.totalFat ?? 0,
+ serving: savedFood.servingSize,
+ };
+}
+
diff --git a/supabase/migrations/20251127215904_remote_schema.sql b/supabase/migrations/20251127215904_remote_schema.sql
index eaa436c..3cb2718 100644
--- a/supabase/migrations/20251127215904_remote_schema.sql
+++ b/supabase/migrations/20251127215904_remote_schema.sql
@@ -1,5 +1,4 @@
-\restrict DcxICQMG3WtocX04KgJgHJjqCoplADZdRfMVaaO1T542gYKsohKw8DWHBaQVNxg
SET statement_timeout = 0;
@@ -516,6 +515,5 @@ ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TAB
-\unrestrict DcxICQMG3WtocX04KgJgHJjqCoplADZdRfMVaaO1T542gYKsohKw8DWHBaQVNxg
RESET ALL;
diff --git a/supabase/migrations/20251129120000_create_foods_table.sql b/supabase/migrations/20251129120000_create_foods_table.sql
new file mode 100644
index 0000000..de71a75
--- /dev/null
+++ b/supabase/migrations/20251129120000_create_foods_table.sql
@@ -0,0 +1,36 @@
+create table if not exists public.foods (
+ id uuid default gen_random_uuid() primary key,
+ profile_id uuid default auth.uid() not null references public.profiles(id) on delete cascade,
+ brand_name text,
+ description text not null,
+ serving_size text not null,
+ servings_per_container numeric(6,2) not null default 1 constraint servings_per_container_positive check (servings_per_container > 0),
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+create index if not exists foods_profile_description_idx on public.foods (profile_id, description);
+
+alter table public.foods enable row level security;
+
+create policy "Insert own foods" on public.foods
+ for insert
+ to authenticated
+ with check ((select auth.uid()) = profile_id);
+
+create policy "Select own foods" on public.foods
+ for select
+ to authenticated
+ using ((select auth.uid()) = profile_id);
+
+create policy "Update own foods" on public.foods
+ for update
+ to authenticated
+ using ((select auth.uid()) = profile_id)
+ with check ((select auth.uid()) = profile_id);
+
+create policy "Delete own foods" on public.foods
+ for delete
+ to authenticated
+ using ((select auth.uid()) = profile_id);
+
diff --git a/supabase/migrations/20251129130000_extend_foods_nutrition.sql b/supabase/migrations/20251129130000_extend_foods_nutrition.sql
new file mode 100644
index 0000000..4e4bad3
--- /dev/null
+++ b/supabase/migrations/20251129130000_extend_foods_nutrition.sql
@@ -0,0 +1,17 @@
+alter table public.foods
+ add column if not exists calories integer not null default 0,
+ add column if not exists total_fat numeric(6,2),
+ add column if not exists saturated_fat numeric(6,2),
+ add column if not exists polyunsaturated_fat numeric(6,2),
+ add column if not exists monounsaturated_fat numeric(6,2),
+ add column if not exists trans_fat numeric(6,2),
+ add column if not exists cholesterol integer,
+ add column if not exists sodium integer,
+ add column if not exists potassium integer,
+ add column if not exists total_carbohydrates numeric(6,2),
+ add column if not exists dietary_fiber numeric(6,2),
+ add column if not exists sugars numeric(6,2),
+ add column if not exists added_sugars numeric(6,2),
+ add column if not exists sugar_alcohols numeric(6,2),
+ add column if not exists protein numeric(6,2);
+
diff --git a/types/index.ts b/types/index.ts
index 26a0eab..50546bd 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -109,6 +109,84 @@ export interface FoodSearchResult {
metadata?: Record;
}
+export interface SavedFood {
+ id: string;
+ profileId: string;
+ brandName?: string;
+ description: string;
+ servingSize: string;
+ servingsPerContainer: number;
+ calories: number;
+ totalFat?: number;
+ saturatedFat?: number;
+ polyunsaturatedFat?: number;
+ monounsaturatedFat?: number;
+ transFat?: number;
+ cholesterol?: number;
+ sodium?: number;
+ potassium?: number;
+ totalCarbohydrates?: number;
+ dietaryFiber?: number;
+ sugars?: number;
+ addedSugars?: number;
+ sugarAlcohols?: number;
+ protein?: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface SavedFoodRecord {
+ id: string;
+ profile_id: string;
+ brand_name: string | null;
+ description: string;
+ serving_size: string;
+ servings_per_container: number | string;
+ calories: number | string;
+ total_fat: number | string | null;
+ saturated_fat: number | string | null;
+ polyunsaturated_fat: number | string | null;
+ monounsaturated_fat: number | string | null;
+ trans_fat: number | string | null;
+ cholesterol: number | string | null;
+ sodium: number | string | null;
+ potassium: number | string | null;
+ total_carbohydrates: number | string | null;
+ dietary_fiber: number | string | null;
+ sugars: number | string | null;
+ added_sugars: number | string | null;
+ sugar_alcohols: number | string | null;
+ protein: number | string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface SavedFoodInput {
+ brandName?: string;
+ description: string;
+ servingSize: string;
+ servingsPerContainer: number;
+ calories: number;
+ totalFat?: number;
+ saturatedFat?: number;
+ polyunsaturatedFat?: number;
+ monounsaturatedFat?: number;
+ transFat?: number;
+ cholesterol?: number;
+ sodium?: number;
+ potassium?: number;
+ totalCarbohydrates?: number;
+ dietaryFiber?: number;
+ sugars?: number;
+ addedSugars?: number;
+ sugarAlcohols?: number;
+ protein?: number;
+}
+
+export interface SavedFoodUpdateInput extends SavedFoodInput {
+ id: string;
+}
+
export type FoodSearchAdapterId = 'open-food-facts' | 'ai-fast';
export interface FoodSearchRequest extends FoodSearchParams {
@@ -373,3 +451,48 @@ export type AppStackScreenOptions = NativeStackNavigationOptions & {
headerBackTitleVisible?: boolean;
};
+export interface OutreachSearchDefinition {
+ id: string;
+ label: string;
+ query: string;
+ subreddit?: string;
+ sort?: 'relevance' | 'new' | 'top' | 'hot';
+ timeframe?: 'hour' | 'day' | 'week' | 'month' | 'year' | 'all';
+ keywords?: string[];
+ angle?: string;
+ valueProp?: string;
+ commentTemplate?: string;
+ notes?: string;
+}
+
+export interface OutreachTargetHit {
+ id: string;
+ queryId: string;
+ title: string;
+ permalink: string;
+ url: string;
+ author: string;
+ subreddit: string;
+ flair?: string | null;
+ createdUtc: number;
+ upvotes: number;
+ comments: number;
+ matchScore: number;
+ ageHours: number;
+ keywordsMatched: string[];
+ summary?: string;
+ angle?: string;
+ valueProp?: string;
+ commentTemplate?: string;
+ status: 'new' | 'drafted' | 'posted' | 'skip';
+}
+
+export interface OutreachDigest {
+ generatedAt: string;
+ queries: Array<{
+ definition: OutreachSearchDefinition;
+ hits: OutreachTargetHit[];
+ error?: string;
+ }>;
+}
+