The app has two ways to turn something outside the food library into macros: a barcode lookup and an AI analysis of a photo or a text description. Both run on the server. The app sends the input, shows the result and decides where it lands in the day log.

Entry points

AddComponentSheet and MealAiLogger both embed BarcodeScanSheet.

Barcode scan

src/features/nutrition/components/BarcodeScanSheet.tsx. Props: visible, onAdd(item), onClose.

Lookup

The code comment says the data comes from Open Food Facts through the server proxy. The response is a discriminated union:
Values are per 100 g. serving is the producer’s declared portion when known.

Scanning

  • The camera is an expo-camera CameraView with barcodeTypes set to ean13, ean8, upc_a and upc_e. Permission comes from useCameraPermissions. Without it the sheet shows a prompt and a grant button.
  • The camera fires the same code on every frame. lockRef allows one lookup at a time. It is released when the product is not found or the request fails, and stays locked once a product is shown.
  • A torch toggle is shown while the camera is live.
  • A failed request and a found: false response both show the same “not found” message.

Manual entry and the keyboard

Below the camera is a number field (keyboardType="number-pad", maxLength 14). submitManual strips non-digits and looks the code up only when at least 8 digits remain. The lookup button is disabled under 8 digits and while loading. Keyboard handling:
  • The modal root is a KeyboardAvoidingView with behavior="padding" on both platforms and keyboardVerticalOffset set to minus the bottom safe-area inset.
  • The scan area, the spacer above the product card and the card itself are Pressable wrappers that call Keyboard.dismiss. A number pad has no return key, so tapping outside is how it closes.
See Keyboard and Screen for the general rules.

Product card

When a product is found, the camera is replaced by a card with the image, name, brand, serving label and a PortionStepper in grams. The starting amount is product.serving.grams or 100. Live values use barcodeScaled(product, grams): each per 100 g value times grams / 100. When MBP is on, the card also shows totalMbp(scaledBarcodeMbp(product, grams, scaled, anchors)). The formulas are in Portions and MBP. Adding builds the item with barcodeItem(product, grams, anchors):
  • id from localId('scan').
  • name is the product name, with the brand appended after a middle dot when there is one.
  • eaten: false, unit: 'gram', qty: grams, a per 100 g base and the product itself as barcode, so the amount can be changed later.

The iOS dismissal order

BarcodeScanSheet is a modal opened from inside another modal. On iOS, dismissing a nested modal and its parent in the same tick orphans the inner presentation layer, which then swallows every touch. So on iOS the item is parked in pendingAddRef, the sheet closes, and onDismiss hands the item to the parent through flushPendingAdd. The parent closes itself only after that. On Android the sheet closes and calls onAdd straight away.

AI meal analysis

Request and response

The app sends either text, or image with mimeType. useAnalyzeMeal() is a plain mutation with no cache. The mobile code reads name, the four macro totals, note and mbp. items is in the type but no screen in this feature renders it.

Taking the photo

useMealImage() in hooks/useMealImage.ts returns takePhoto and pickFromGallery. Each requests its permission, opens expo-image-picker with mediaTypes: ['images'] and quality: 0.5 (MEAL_IMAGE_QUALITY), then:
  1. Runs the asset through shrinkImage(uri, { base64: true }) from src/lib/media/shrinkImage.ts. That helper caps the longest edge at 1600 px and saves a JPEG.
  2. If shrinking is unavailable, reads the original file as base64 and keeps its own mime type.
  3. Returns { uri, base64, mimeType } or null when the trainee cancels or the read fails.
PhotoSourceSheet is the camera or gallery chooser.

Errors

Every caller catches the mutation error and passes it to handleMealAnalyzeError(err, fallback) in lib/meal-easter-egg.ts.
  • When the error is an ApiError whose body has details.easterEgg equal to 'alcohol' or 'cannabis', the app shows an Alert with a dedicated message and does not show a failure.
  • Anything else runs the fallback, which shows the generic strings.analyzeFailed message inline or as a toast.
Those two values are the only server reasons this feature distinguishes. A network failure, a timeout and a server error all read as the same generic failure.

When the server sends no MBP

When the analysis has no mbp, each caller prices it as a single item: portionsForFood with the total kcal and no macro type, so the dominant macro’s anchor is used. Server mbp always leads when present.

Flow 1: quick add from the tab

1

Pick a source on /meal/add

AddMealScreen has three tabs from SourceTabs: history, favorites and new. The new tab shows AddNewProductPanel (photo or text) until the search field has at least 2 characters, then it shows food search results from useFoodSearch. The mode route param auto-starts the panel: photo opens the source sheet, text opens the text form.
2

Build a draft

Every path ends in openResult(...), which writes one MealDraft to useMealDraftStore and pushes /meal/result.
3

Review on /meal/result

With no draft the screen redirects to the nutrition tab. Otherwise it shows the photo, MealMacroDonuts, and the AI note when there is one. A library food gets a PortionStepper and its macros are recomputed with scalePortion. Anything else gets MealRefineCard: a correction is sent back as text: "<name>. <correction>" to the same analyze endpoint and replaces the draft’s analysis through updateAnalysis.
4

Add to the menu

onAddToMenu uploads the photo if there is one, logs the meal, then writes it to the day log.
The log request:
  • photoUrl comes from uploadFile in src/lib/api/upload.ts (see Tracking and uploads). The uploaded url is cached in a ref so the favorite toggle and the log reuse one upload. A failed upload does not block the log. The meal is saved without a photo.
  • consumedAt is only sent when the meal is for yesterday. See the editable window in Day log and sync.
A meal log on its own is not visible on the nutrition tab, which renders the plan and the day snapshot. So after the log succeeds the screen also calls:
The item is marked eaten because the trainee is logging something they just ate. logId ties the custom meal to the log so the day is not counted twice, and so deleting the meal can delete the log. The screen then shows a toast, clears the draft and calls router.dismissAll(). The star in the header saves or removes a favorite through POST and DELETE /v1/trainee/nutrition/favorites. A 'PLAN' source is stored as 'MANUAL'.

Flow 2: replace a plan meal

MealAiLogger (components/MealAiLogger.tsx) is opened from the footer of MealScreen. Props: mealLabel, onLogged(item), onBarcodeAdded(item). It offers two tiles and a text field. Each tile states what it does to the meal:
  • Photo replaces the whole meal.
  • Barcode adds one product beside what is already there.
  • Text analyzes a description and also replaces the whole meal.
After an analysis the result card shows the name and macros (or MBP per macro). The add button calls addMeal:
  1. logQuickMeal with the analysis totals, note: mealLabel, source 'PHOTO' or 'FREE_TEXT', and consumedAt when the date is not today.
  2. onLogged with an item whose id is the returned log id and eaten: true.
MealScreen.onAiReplaceLogged then calls setReplacement(dateKey, meal.id, …) with the name, macros and MBP. That sets the meal’s status to 'REPLACED'. From then on mealTotalMacros and eatenMealMacros return the replacement instead of the plan items, and the footer becomes an undo button that calls setReplacement(dateKey, meal.id, null). A scanned product takes the other path. onBarcodeAdded calls addAiMeal(dateKey, meal.id, item), the same as adding from the components toolbar.
The photo is analyzed but not uploaded in this flow. addMeal sends no photoUrl. Only the quick add flow uploads the image.

Flow 3: add a food to a meal

AddComponentSheet (components/AddComponentSheet.tsx) is used by MealScreen and MealComposerScreen. Props: visible, optional hint, onAdd(item, source), onClose. It combines three inputs:
  • Search. useFoodSearch on a 250 ms debounced query, from 2 characters, showing the first 8 results. A result becomes an item through foodToComponent: default unit and quantity, scalePortion for macros, scaledFoodMbp for MBP, eaten: false. The search row rides along as source so the amount can be changed later.
  • Barcode. Opens BarcodeScanSheet.
  • Text. One analyze call, then an add button. The item has eaten: false and no amount to change.
Nothing in this sheet writes a meal log. In MealScreen the item goes to addAiMeal. In the composer it goes into the draft and is saved with the meal through upsertCustomMeal.

How results reach the server

All three flows end in the session store, and useNutritionDaySync saves the day snapshot with PUT /v1/trainee/nutrition/day after a 600 ms debounce. The quick add and replace flows also write a row to the meal log first.