diff --git a/apps/admin/src/app/api/food/barcode/[code]/route.ts b/apps/admin/src/app/api/food/barcode/[code]/route.ts new file mode 100644 index 0000000..f5a98d9 --- /dev/null +++ b/apps/admin/src/app/api/food/barcode/[code]/route.ts @@ -0,0 +1,156 @@ +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { getUserMembershipContext } from "@/lib/membership/access"; +import log from "@/lib/logger"; + +interface OpenFoodFactsProduct { + product_name?: string; + product_name_en?: string; + brands?: string; + image_url?: string; + image_front_url?: string; + serving_size?: string; + nutriments?: { + [key: string]: number | string | undefined; + "energy-kcal_serving"?: number; + "energy-kcal_100g"?: number; + proteins_serving?: number; + proteins_100g?: number; + carbohydrates_serving?: number; + carbohydrates_100g?: number; + fat_serving?: number; + fat_100g?: number; + }; +} + +interface OpenFoodFactsResponse { + status: number; + code: string; + product?: OpenFoodFactsProduct; +} + +function normalizeBarcode(rawCode: string): string { + return rawCode.replace(/\D/g, "").trim(); +} + +function isSupportedBarcode(code: string): boolean { + return [8, 12, 13].includes(code.length); +} + +function getNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +function buildProductPayload(code: string, product: OpenFoodFactsProduct) { + const caloriesPerServing = + getNumber(product.nutriments?.["energy-kcal_serving"]) ?? + getNumber(product.nutriments?.["energy-kcal_100g"]) ?? + 0; + + const protein = + getNumber(product.nutriments?.proteins_serving) ?? + getNumber(product.nutriments?.proteins_100g); + const carbs = + getNumber(product.nutriments?.carbohydrates_serving) ?? + getNumber(product.nutriments?.carbohydrates_100g); + const fat = + getNumber(product.nutriments?.fat_serving) ?? + getNumber(product.nutriments?.fat_100g); + + return { + barcode: code, + name: product.product_name || product.product_name_en || "Unknown Product", + brand: product.brands || null, + imageUrl: product.image_url || product.image_front_url || null, + servingSize: product.serving_size || "1 serving", + caloriesPerServing: Math.max(0, Math.round(caloriesPerServing)), + macros: { + protein: protein ?? null, + carbs: carbs ?? null, + fat: fat ?? null, + }, + source: "openfoodfacts" as const, + }; +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ code: string }> }, +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { features, membershipType } = await getUserMembershipContext(userId); + if (!features.nutritionTracking) { + return NextResponse.json( + { + error: + "Barcode food scan is available on Premium and VIP memberships", + membershipType, + }, + { status: 403 }, + ); + } + + const { code: rawCode } = await params; + const code = normalizeBarcode(rawCode); + + if (!isSupportedBarcode(code)) { + return NextResponse.json( + { error: "Invalid barcode. Use EAN-8, UPC-A, or EAN-13 formats." }, + { status: 400 }, + ); + } + + const response = await fetch( + `https://world.openfoodfacts.org/api/v2/product/${code}.json`, + { + headers: { + "User-Agent": "FitAI/1.0 (fitai.app)", + }, + cache: "no-store", + }, + ); + + if (!response.ok) { + log.warn("OpenFoodFacts lookup failed", { + status: response.status, + barcode: code, + }); + return NextResponse.json( + { error: "Food lookup service unavailable. Please try again." }, + { status: 503 }, + ); + } + + const payload = (await response.json()) as OpenFoodFactsResponse; + if (payload.status !== 1 || !payload.product) { + return NextResponse.json( + { error: "Product not found in OpenFoodFacts" }, + { status: 404 }, + ); + } + + return NextResponse.json({ + success: true, + data: buildProductPayload(code, payload.product), + meta: { + timestamp: new Date().toISOString(), + }, + }); + } catch (error) { + log.error("Failed barcode food lookup", error); + return NextResponse.json( + { error: "Failed to lookup food barcode" }, + { status: 500 }, + ); + } +} diff --git a/apps/mobile/src/api/food.ts b/apps/mobile/src/api/food.ts new file mode 100644 index 0000000..b41a4d5 --- /dev/null +++ b/apps/mobile/src/api/food.ts @@ -0,0 +1,35 @@ +import { apiClient, withAuth } from "./client"; +import { API_ENDPOINTS } from "../config/api"; + +export interface ScannedFoodProduct { + barcode: string; + name: string; + brand: string | null; + imageUrl: string | null; + servingSize: string; + caloriesPerServing: number; + macros: { + protein: number | null; + carbs: number | null; + fat: number | null; + }; + source: "openfoodfacts"; +} + +interface FoodLookupResponse { + success: boolean; + data: ScannedFoodProduct; +} + +export async function lookupFoodByBarcode( + barcode: string, + token: string | null, +): Promise { + const normalized = barcode.replace(/\D/g, ""); + const response = await apiClient.get( + API_ENDPOINTS.FOOD.LOOKUP_BARCODE(normalized), + withAuth(token), + ); + + return response.data.data; +} diff --git a/apps/mobile/src/api/index.ts b/apps/mobile/src/api/index.ts index 0acc3c8..a91c11f 100644 --- a/apps/mobile/src/api/index.ts +++ b/apps/mobile/src/api/index.ts @@ -15,4 +15,5 @@ export * from "./hydration"; export * from "./client"; export * from "./helpers"; export * from "./membership"; +export * from "./food"; export * from "./gyms"; diff --git a/apps/mobile/src/components/ScanFoodModal.tsx b/apps/mobile/src/components/ScanFoodModal.tsx index dfaa5c0..fbeffb9 100644 --- a/apps/mobile/src/components/ScanFoodModal.tsx +++ b/apps/mobile/src/components/ScanFoodModal.tsx @@ -1,448 +1,715 @@ -import React, { useState, useEffect } from 'react'; -import { View, Text, StyleSheet, Modal, TouchableOpacity, TextInput, Alert } from 'react-native'; -import { CameraView, useCameraPermissions } from 'expo-camera'; -import { Ionicons } from '@expo/vector-icons'; -import { LinearGradient } from 'expo-linear-gradient'; -import { theme } from '../styles/theme'; +import React, { useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Alert, + KeyboardAvoidingView, + Modal, + Platform, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { CameraView, useCameraPermissions } from "expo-camera"; +import { useAuth } from "@clerk/clerk-expo"; +import { Ionicons } from "@expo/vector-icons"; +import { LinearGradient } from "expo-linear-gradient"; +import { addMealEntry } from "../api/nutrition"; +import { lookupFoodByBarcode, type ScannedFoodProduct } from "../api/food"; +import { theme } from "../styles/theme"; interface ScanFoodModalProps { - visible: boolean; - onClose: () => void; - onAddFood: (calories: number) => void; + visible: boolean; + onClose: () => void; + onAddFood: (calories: number) => void; } -// Mock food database -const FOOD_DATABASE: { [key: string]: { name: string; calories: number; servingSize: string } } = { - '0123456789': { name: 'Apple', calories: 95, servingSize: '1 medium' }, - '9876543210': { name: 'Banana', calories: 105, servingSize: '1 medium' }, - '5555555555': { name: 'Protein Bar', calories: 200, servingSize: '1 bar' }, - '1111111111': { name: 'Greek Yogurt', calories: 150, servingSize: '1 cup' }, -}; +const MEAL_TYPES = ["breakfast", "lunch", "dinner", "snack"] as const; +type MealType = (typeof MEAL_TYPES)[number]; -export function ScanFoodModal({ visible, onClose, onAddFood }: ScanFoodModalProps) { - const [permission, requestPermission] = useCameraPermissions(); - const [scanned, setScanned] = useState(false); - const [foodData, setFoodData] = useState<{ name: string; calories: number; servingSize: string } | null>(null); - const [servings, setServings] = useState('1'); +export function ScanFoodModal({ + visible, + onClose, + onAddFood, +}: ScanFoodModalProps) { + const [permission, requestPermission] = useCameraPermissions(); + const { getToken } = useAuth(); + const [scanned, setScanned] = useState(false); + const [isLookingUp, setIsLookingUp] = useState(false); + const [foodData, setFoodData] = useState(null); + const [notFound, setNotFound] = useState(false); + const [manualFoodName, setManualFoodName] = useState("Manual food entry"); + const [manualCalories, setManualCalories] = useState(""); + const [servings, setServings] = useState("1"); + const [mealType, setMealType] = useState("snack"); + const lastScannedRef = useRef<{ code: string; ts: number } | null>(null); - useEffect(() => { - if (visible) { - setScanned(false); - setFoodData(null); - setServings('1'); - } - }, [visible]); + useEffect(() => { + if (!visible) return; + setScanned(false); + setIsLookingUp(false); + setFoodData(null); + setNotFound(false); + setManualFoodName("Manual food entry"); + setManualCalories(""); + setServings("1"); + setMealType("snack"); + }, [visible]); - const handleBarCodeScanned = ({ data }: { data: string }) => { - if (scanned) return; + const handleReset = () => { + setScanned(false); + setIsLookingUp(false); + setFoodData(null); + setNotFound(false); + setManualFoodName("Manual food entry"); + setManualCalories(""); + setServings("1"); + setMealType("snack"); + }; - setScanned(true); + const handleBarCodeScanned = async ({ data }: { data: string }) => { + if (scanned) return; - // Look up food in database - const food = FOOD_DATABASE[data]; - - if (food) { - setFoodData(food); - } else { - // Mock data for unknown barcodes - setFoodData({ - name: 'Unknown Food', - calories: 150, - servingSize: '1 serving' - }); - } - }; - - const handleConfirm = () => { - if (foodData) { - const totalCalories = foodData.calories * parseFloat(servings || '1'); - onAddFood(totalCalories); - onClose(); - } - }; - - const handleRescan = () => { - setScanned(false); - setFoodData(null); - setServings('1'); - }; - - if (!permission) { - return null; + const normalized = data.replace(/\D/g, ""); + const now = Date.now(); + if ( + lastScannedRef.current && + lastScannedRef.current.code === normalized && + now - lastScannedRef.current.ts < 2500 + ) { + return; } + lastScannedRef.current = { code: normalized, ts: now }; - if (!permission.granted) { - return ( - - - - - Camera Permission Required - - We need access to your camera to scan food barcodes. - - - - Grant Permission - - - - Cancel - - - - + setScanned(true); + setIsLookingUp(true); + setNotFound(false); + setFoodData(null); + + try { + const token = await getToken(); + const product = await lookupFoodByBarcode(normalized, token); + setFoodData(product); + } catch (error: any) { + const status = error?.response?.status; + if (status === 404) { + setNotFound(true); + } else if (status === 403) { + Alert.alert( + "Premium Feature", + "Barcode food scanning is available on Premium and VIP memberships.", ); + onClose(); + } else { + Alert.alert("Scan Failed", "Unable to lookup this barcode right now."); + setScanned(false); + } + } finally { + setIsLookingUp(false); + } + }; + + const getTotalCalories = () => { + if (foodData) { + return Math.round( + foodData.caloriesPerServing * parseFloat(servings || "1"), + ); } + const manual = parseInt(manualCalories || "0", 10); + return Number.isFinite(manual) && manual > 0 ? manual : 0; + }; + + const handleConfirm = async () => { + const totalCalories = getTotalCalories(); + if (totalCalories <= 0) { + Alert.alert("Invalid calories", "Please enter a valid calorie amount."); + return; + } + + try { + const token = await getToken(); + await addMealEntry( + { + mealType, + foodName: foodData?.name || manualFoodName, + calories: totalCalories, + protein: foodData?.macros.protein ?? undefined, + carbs: foodData?.macros.carbs ?? undefined, + fats: foodData?.macros.fat ?? undefined, + }, + token, + ); + + onAddFood(totalCalories); + onClose(); + } catch { + Alert.alert( + "Could not save", + "Failed to add scanned food to nutrition diary.", + ); + } + }; + + if (!permission) { + return null; + } + + if (!permission.granted) { return ( - - - {!foodData ? ( - <> - - - - - Scan Food Barcode - - - - - - - Position barcode within frame - - - - ) : ( - - - - - - Food Details - - - - - - - - - - - {foodData.name} - {foodData.servingSize} - - - {foodData.calories} - kcal per serving - - - - Number of Servings - - setServings(String(Math.max(0.5, parseFloat(servings || '1') - 0.5)))} - style={styles.servingsButton} - > - - - - setServings(String(parseFloat(servings || '1') + 0.5))} - style={styles.servingsButton} - > - - - - - - - Total Calories - - {Math.round(foodData.calories * parseFloat(servings || '1'))} kcal - - - - - - - Scan Again - - - - - Add to Diary - - - - - )} - - + + + + + + Camera Permission Required + + + We need access to your camera to scan food barcodes. + + + + + Grant Permission + + + + + Cancel + + + + ); + } + + const showScanner = !foodData && !notFound; + + return ( + + + {showScanner ? ( + <> + + + + + Scan Food Barcode + + + + + + + {isLookingUp ? ( + + + Looking up product... + + ) : ( + + Position barcode within frame + + )} + + + + ) : ( + + + + + + + {notFound ? "Barcode Not Found" : "Food Details"} + + + + + + {!notFound && foodData ? ( + <> + + + + + + + {foodData.name} + {foodData.brand ? ( + {foodData.brand} + ) : null} + {foodData.servingSize} + + + + {foodData.caloriesPerServing} + + kcal per serving + + + + Number of Servings + + + setServings( + String( + Math.max(0.5, parseFloat(servings || "1") - 0.5), + ), + ) + } + style={styles.servingsButton} + > + + + + + setServings(String(parseFloat(servings || "1") + 0.5)) + } + style={styles.servingsButton} + > + + + + + + ) : ( + <> + + We could not find this product in OpenFoodFacts. + + + Food Name + + + Calories + + + kcal + + + )} + + + Meal Type + + {MEAL_TYPES.map((type) => { + const active = mealType === type; + return ( + setMealType(type)} + style={[ + styles.mealTypeChip, + active && styles.mealTypeChipActive, + ]} + > + + {type.charAt(0).toUpperCase() + type.slice(1)} + + + ); + })} + + + + + Total Calories + {getTotalCalories()} kcal + + + + + + Scan Again + + + + + + {notFound ? "Add Manual" : "Add to Diary"} + + + + + + )} + + + ); } const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#000', - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 20, - paddingTop: 60, - backgroundColor: 'rgba(0, 0, 0, 0.8)', - }, - closeButton: { - padding: 4, - }, - title: { - fontSize: 18, - fontWeight: '700', - color: '#fff', - }, - camera: { - flex: 1, - }, - scanOverlay: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.3)', - }, - scanFrame: { - width: 250, - height: 250, - borderWidth: 2, - borderColor: '#fff', - borderRadius: 20, - backgroundColor: 'transparent', - }, - scanText: { - color: '#fff', - fontSize: 16, - fontWeight: '600', - marginTop: 24, - textAlign: 'center', - }, - resultContainer: { - flex: 1, - backgroundColor: theme.colors.background, - }, - resultHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 20, - paddingTop: 60, - backgroundColor: '#fff', - }, - resultTitle: { - fontSize: 18, - fontWeight: '700', - color: theme.colors.gray900, - }, - foodCard: { - margin: 20, - padding: 24, - backgroundColor: '#fff', - borderRadius: 24, - alignItems: 'center', - ...theme.shadows.medium, - }, - foodIconContainer: { - marginBottom: 16, - }, - foodIcon: { - width: 80, - height: 80, - borderRadius: 40, - justifyContent: 'center', - alignItems: 'center', - }, - foodName: { - fontSize: 24, - fontWeight: '700', - color: theme.colors.gray900, - marginBottom: 8, - textAlign: 'center', - }, - servingSize: { - fontSize: 16, - color: theme.colors.gray600, - marginBottom: 24, - }, - caloriesBadge: { - backgroundColor: theme.colors.gray50, - paddingHorizontal: 24, - paddingVertical: 16, - borderRadius: 16, - alignItems: 'center', - marginBottom: 32, - }, - caloriesValue: { - fontSize: 32, - fontWeight: '700', - color: theme.colors.primary, - }, - caloriesLabel: { - fontSize: 14, - color: theme.colors.gray600, - marginTop: 4, - }, - servingsContainer: { - width: '100%', - marginBottom: 24, - }, - label: { - fontSize: 16, - fontWeight: '600', - color: theme.colors.gray700, - marginBottom: 12, - }, - servingsInput: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 16, - }, - servingsButton: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: theme.colors.gray100, - justifyContent: 'center', - alignItems: 'center', - }, - servingsValue: { - fontSize: 24, - fontWeight: '700', - color: theme.colors.gray900, - textAlign: 'center', - minWidth: 60, - }, - totalCalories: { - width: '100%', - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingTop: 24, - borderTopWidth: 1, - borderTopColor: theme.colors.gray200, - }, - totalLabel: { - fontSize: 16, - fontWeight: '600', - color: theme.colors.gray700, - }, - totalValue: { - fontSize: 20, - fontWeight: '700', - color: theme.colors.gray900, - }, - buttonRow: { - flexDirection: 'row', - padding: 20, - gap: 12, - }, - rescanButton: { - flex: 1, - paddingVertical: 16, - borderRadius: 20, - backgroundColor: theme.colors.gray100, - alignItems: 'center', - }, - rescanButtonText: { - fontSize: 16, - fontWeight: '700', - color: theme.colors.gray700, - }, - confirmButtonContainer: { - flex: 1, - }, - confirmButton: { - paddingVertical: 16, - borderRadius: 20, - alignItems: 'center', - }, - confirmButtonText: { - fontSize: 16, - fontWeight: '700', - color: '#fff', - }, - permissionContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.5)', - }, - permissionContent: { - backgroundColor: '#fff', - borderRadius: 24, - padding: 32, - margin: 20, - alignItems: 'center', - ...theme.shadows.strong, - }, - permissionTitle: { - fontSize: 20, - fontWeight: '700', - color: theme.colors.gray900, - marginTop: 16, - marginBottom: 8, - }, - permissionText: { - fontSize: 16, - color: theme.colors.gray600, - textAlign: 'center', - marginBottom: 24, - }, - permissionButtonContainer: { - width: '100%', - marginBottom: 12, - }, - permissionButton: { - paddingVertical: 16, - borderRadius: 20, - alignItems: 'center', - }, - permissionButtonText: { - fontSize: 16, - fontWeight: '700', - color: '#fff', - }, - cancelButton: { - paddingVertical: 12, - }, - cancelButtonText: { - fontSize: 16, - fontWeight: '600', - color: theme.colors.gray600, - }, + container: { + flex: 1, + backgroundColor: "#000", + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 20, + paddingTop: 60, + backgroundColor: "rgba(0, 0, 0, 0.8)", + }, + closeButton: { + padding: 4, + }, + title: { + fontSize: 18, + fontWeight: "700", + color: "#fff", + }, + camera: { + flex: 1, + }, + scanOverlay: { + flex: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: "rgba(0, 0, 0, 0.3)", + }, + scanFrame: { + width: 250, + height: 250, + borderWidth: 2, + borderColor: "#fff", + borderRadius: 20, + backgroundColor: "transparent", + }, + scanText: { + color: "#fff", + fontSize: 16, + fontWeight: "600", + marginTop: 16, + textAlign: "center", + }, + lookupContainer: { + alignItems: "center", + marginTop: 24, + }, + resultContainer: { + flex: 1, + backgroundColor: theme.colors.background, + }, + resultHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 20, + paddingTop: 60, + backgroundColor: "#fff", + }, + resultTitle: { + fontSize: 18, + fontWeight: "700", + color: theme.colors.gray900, + }, + foodCard: { + margin: 20, + padding: 24, + backgroundColor: "#fff", + borderRadius: 24, + alignItems: "center", + ...theme.shadows.medium, + }, + foodIconContainer: { + marginBottom: 16, + }, + foodIcon: { + width: 80, + height: 80, + borderRadius: 40, + justifyContent: "center", + alignItems: "center", + }, + foodName: { + fontSize: 24, + fontWeight: "700", + color: theme.colors.gray900, + marginBottom: 8, + textAlign: "center", + }, + servingSize: { + fontSize: 16, + color: theme.colors.gray600, + marginBottom: 16, + textAlign: "center", + }, + caloriesBadge: { + backgroundColor: theme.colors.gray50, + paddingHorizontal: 24, + paddingVertical: 16, + borderRadius: 16, + alignItems: "center", + marginBottom: 24, + }, + caloriesValue: { + fontSize: 32, + fontWeight: "700", + color: theme.colors.primary, + }, + caloriesLabel: { + fontSize: 14, + color: theme.colors.gray600, + marginTop: 4, + }, + servingsContainer: { + width: "100%", + marginBottom: 24, + }, + mealTypeContainer: { + width: "100%", + marginBottom: 24, + }, + mealTypeRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + }, + mealTypeChip: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 18, + backgroundColor: theme.colors.gray100, + borderWidth: 1, + borderColor: theme.colors.gray200, + }, + mealTypeChipActive: { + backgroundColor: theme.colors.primary, + borderColor: theme.colors.primary, + }, + mealTypeText: { + fontSize: 13, + fontWeight: "600", + color: theme.colors.gray700, + }, + mealTypeTextActive: { + color: "#fff", + }, + label: { + fontSize: 16, + fontWeight: "600", + color: theme.colors.gray700, + marginBottom: 12, + alignSelf: "flex-start", + }, + servingsInput: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 16, + }, + servingsButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: theme.colors.gray100, + justifyContent: "center", + alignItems: "center", + }, + servingsValue: { + fontSize: 24, + fontWeight: "700", + color: theme.colors.gray900, + textAlign: "center", + minWidth: 60, + }, + input: { + width: "100%", + backgroundColor: theme.colors.gray50, + borderWidth: 1, + borderColor: theme.colors.gray200, + borderRadius: 16, + padding: 16, + fontSize: 16, + color: theme.colors.gray900, + marginBottom: 16, + }, + caloriesInputContainer: { + width: "100%", + flexDirection: "row", + alignItems: "center", + }, + caloriesInput: { + flex: 1, + marginBottom: 0, + marginRight: 12, + }, + caloriesUnit: { + fontSize: 16, + fontWeight: "600", + color: theme.colors.gray500, + }, + totalCalories: { + width: "100%", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingTop: 24, + borderTopWidth: 1, + borderTopColor: theme.colors.gray200, + }, + totalLabel: { + fontSize: 16, + fontWeight: "600", + color: theme.colors.gray700, + }, + totalValue: { + fontSize: 20, + fontWeight: "700", + color: theme.colors.gray900, + }, + buttonRow: { + flexDirection: "row", + padding: 20, + gap: 12, + }, + rescanButton: { + flex: 1, + paddingVertical: 16, + borderRadius: 20, + backgroundColor: theme.colors.gray100, + alignItems: "center", + }, + rescanButtonText: { + fontSize: 16, + fontWeight: "700", + color: theme.colors.gray700, + }, + confirmButtonContainer: { + flex: 1, + }, + confirmButton: { + paddingVertical: 16, + borderRadius: 20, + alignItems: "center", + }, + confirmButtonText: { + fontSize: 16, + fontWeight: "700", + color: "#fff", + }, + permissionContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: "rgba(0, 0, 0, 0.5)", + }, + permissionContent: { + backgroundColor: "#fff", + borderRadius: 24, + padding: 32, + margin: 20, + alignItems: "center", + ...theme.shadows.strong, + }, + permissionTitle: { + fontSize: 20, + fontWeight: "700", + color: theme.colors.gray900, + marginTop: 16, + marginBottom: 8, + }, + permissionText: { + fontSize: 16, + color: theme.colors.gray600, + textAlign: "center", + marginBottom: 24, + }, + permissionButtonContainer: { + width: "100%", + marginBottom: 12, + }, + permissionButton: { + paddingVertical: 16, + borderRadius: 20, + alignItems: "center", + }, + permissionButtonText: { + fontSize: 16, + fontWeight: "700", + color: "#fff", + }, + cancelButton: { + paddingVertical: 12, + }, + cancelButtonText: { + fontSize: 16, + fontWeight: "600", + color: theme.colors.gray600, + }, }); diff --git a/apps/mobile/src/config/api.ts b/apps/mobile/src/config/api.ts index cbce81d..4420dd0 100644 --- a/apps/mobile/src/config/api.ts +++ b/apps/mobile/src/config/api.ts @@ -47,6 +47,9 @@ export const API_ENDPOINTS = { MEMBERSHIP: { FEATURES: "/api/membership/features", }, + FOOD: { + LOOKUP_BARCODE: (code: string) => `/api/food/barcode/${code}`, + }, NUTRITION: { BASE: "/api/nutrition", MEALS: "/api/nutrition/meals",