Entry points
AddComponentSheet and MealAiLogger both embed BarcodeScanSheet.
Barcode scan
src/features/nutrition/components/BarcodeScanSheet.tsx. Props: visible, onAdd(item), onClose.
Lookup
serving is the producer’s declared portion when known.
Scanning
- The camera is an
expo-cameraCameraViewwithbarcodeTypesset toean13,ean8,upc_aandupc_e. Permission comes fromuseCameraPermissions. Without it the sheet shows a prompt and a grant button. - The camera fires the same code on every frame.
lockRefallows 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: falseresponse 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
KeyboardAvoidingViewwithbehavior="padding"on both platforms andkeyboardVerticalOffsetset to minus the bottom safe-area inset. - The scan area, the spacer above the product card and the card itself are
Pressablewrappers that callKeyboard.dismiss. A number pad has no return key, so tapping outside is how it closes.
Product card
When a product is found, the camera is replaced by a card with the image, name, brand, serving label and aPortionStepper 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):
idfromlocalId('scan').nameis the product name, with the brand appended after a middle dot when there is one.eaten: false,unit: 'gram',qty: grams, a per 100 gbaseand the product itself asbarcode, 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
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:
- Runs the asset through
shrinkImage(uri, { base64: true })fromsrc/lib/media/shrinkImage.ts. That helper caps the longest edge at 1600 px and saves a JPEG. - If shrinking is unavailable, reads the original file as base64 and keeps its own mime type.
- Returns
{ uri, base64, mimeType }ornullwhen the trainee cancels or the read fails.
PhotoSourceSheet is the camera or gallery chooser.
Errors
Every caller catches the mutation error and passes it tohandleMealAnalyzeError(err, fallback) in lib/meal-easter-egg.ts.
- When the error is an
ApiErrorwhose body hasdetails.easterEggequal to'alcohol'or'cannabis', the app shows anAlertwith a dedicated message and does not show a failure. - Anything else runs the fallback, which shows the generic
strings.analyzeFailedmessage inline or as a toast.
When the server sends no MBP
When the analysis has nombp, 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.photoUrlcomes fromuploadFileinsrc/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.consumedAtis only sent when the meal is for yesterday. See the editable window in Day log and sync.
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.
addMeal:
logQuickMealwith the analysis totals,note: mealLabel,source'PHOTO'or'FREE_TEXT', andconsumedAtwhen the date is not today.onLoggedwith an item whoseidis the returned log id andeaten: 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.
useFoodSearchon a 250 ms debounced query, from 2 characters, showing the first 8 results. A result becomes an item throughfoodToComponent: default unit and quantity,scalePortionfor macros,scaledFoodMbpfor MBP,eaten: false. The search row rides along assourceso the amount can be changed later. - Barcode. Opens
BarcodeScanSheet. - Text. One analyze call, then an add button. The item has
eaten: falseand no amount to change.
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, anduseNutritionDaySync 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.