OpenFoodJournal — Project Knowledge
This is the single source of truth for any LLM agent working on this project. Read this before writing code. Update it when things change.
Quick Facts
| Key |
Value |
| Platform |
iOS 26.2+ (iPhone) |
| UI Framework |
SwiftUI + Liquid Glass (no #available gating needed) |
| Data Layer |
SwiftData (@Model) + CloudKit Private Database (iCloud.k3vnc.OpenFoodJournal) |
| State Pattern |
@Observable + @Environment injection (no singletons) |
| Bundle ID |
k3vnc.OpenFoodJournal |
| Build System |
Xcode (xcodebuild), no SPM dependencies |
| AI Backend |
Direct Gemini REST API (BYOK — user provides own API key, stored in Keychain) |
| App Entry |
MacrosApp in OpenFoodJournalApp.swift |
Architecture Overview
MacrosApp (creates ModelContainer w/ CloudKit + 4 @Observable services)
└─ ContentView (4-tab TabView)
├─ Journal tab → DailyLogView (WeeklyCalendarStrip, macro summary, meal sections, RadialMenuButton)
├─ Food Bank tab → FoodBankView (searchable, sortable saved food list, swipe-to-edit, "+" menu: Scan/Manual/Search OFF)
├─ History tab → HistoryView (CalendarGridView with progress rings, MacroChartView, macro cards → NutritionDetailView)
└─ Settings tab → SettingsView (goals, health, data export)
Radial FAB: DailyLogView uses RadialMenuButton — a "+" icon at bottom center that fans out Scan / Manual / Containers / Food Bank in an upper semicircle (210°–330°). Supports tap-to-toggle and drag-to-action. Containers are accessed from here instead of a separate tab.
Service injection: All services (NutritionStore, ScanService, HealthKitService, UserGoals, OpenFoodFactsService) are created in MacrosApp.init() and passed via .environment(). Views consume them with @Environment(ServiceType.self). SyncService was removed — CloudKit handles sync natively via ModelConfiguration(cloudKitDatabase:).
Sheet management: DailyLogView uses a single DailyLogSheet enum with .sheet(item:) — never multiple booleans.
Data Models
See references/models.md for full property lists.
DailyLog — @Model, keyed by date normalized to midnight (no @Attribute(.unique) — CloudKit can't enforce uniqueness; app-level dedup in fetchOrCreateLog(for:)). Owns [NutritionEntry]? (optional for CloudKit) via cascade delete. Uses safeEntries computed property for reads.
NutritionEntry — @Model, stores core macros (cal/protein/carbs/fat) + dynamic micronutrients: [String: MicronutrientValue] + brand/serving/servingCount/servingQuantity/servingUnit/servingMappings. @Attribute(.externalStorage) on sourceImage.
SavedFood — @Model, reusable food template in Food Bank. Same fields as NutritionEntry minus meal/log context. Includes lastUsedAt: Date (defaults to createdAt) for "Last Used" sorting. Created from entries, manual input, or directly from ManualEntryView's "Add to Journal & Food Bank" action.
TrackedContainer — @Model, weight-based container tracking. Snapshots food nutrition at creation time. Start weight → final weight → derived consumption via consumedServings math.
UserGoals — @Observable @MainActor, uses @ObservationIgnored @AppStorage for each goal property to avoid property-wrapper conflicts.
Preferences — @Model, singleton row for UI customization. Stores ringSlot1..5 (nutrient IDs for MacroSummaryBar configurable rings). Preferences.current(in:) static factory fetches-or-creates the singleton. Synced to Turso via GET/PUT /api/preferences. Added to ModelContainer in app init. Sheets use @Bindable var prefs: Preferences for direct binding.
MealType — enum: .breakfast, .lunch, .dinner, .snack
ScanMode — enum: .label, .foodPhoto, .barcode, .manual
ServingSize — enum: .mass(grams:), .volume(ml:), .both(grams:ml:). Stores canonical SI values. Has availableUnits: [String] (dimension-appropriate unit list), convert(_:from:to:) -> Double? (handles same-dimension and cross-dimension via density for .both). Static tables: massConversions (g/oz/kg/lb), volumeConversions (mL/cup/tbsp/tsp/fl oz/L). type: String returns "mass"/"volume"/"both" for JSON serialization.
ServingMapping — Codable struct with from: ServingAmount / to: ServingAmount for per-food unit conversions (e.g. 1 cup = 244g). Legacy field kept alongside ServingSize.
MicronutrientValue — Codable struct with value: Double / unit: String for dynamic micronutrient storage
Services
See references/services.md for full API contracts.
NutritionStore — SwiftData CRUD. log(), fetchLog(), fetchLogs(), delete(), saveEntry(), exportCSV(). Pure local operations — CloudKit sync is handled automatically by SwiftData's ModelConfiguration(cloudKitDatabase:). No sync service reference, no fire-and-forget Tasks.
ScanService — Resizes images to max 2000px (UIGraphicsImageRenderer) then JPEG 0.90 before direct Gemini REST API call (generativelanguage.googleapis.com) → NutritionEntry (not yet inserted). Uses GeminiModelConfig static configs: .labelScan (gemini-3.1-flash-lite-preview, MINIMAL thinking) and .foodPhotoScan (gemini-3.1-pro-preview, HIGH thinking). Includes automatic fallback to gemini-2.5-flash/pro on 500/503 errors. Loads API key from KeychainService. User reviews in ScanResultCard before committing. Logs scan duration via ContinuousClock and stores it on NutritionEntry.scanDurationMs.
KeychainService — Static helper for secure Keychain storage (Security framework). Stores Gemini API key under service k3vnc.OpenFoodJournal, account gemini-api-key. Methods: save(_:for:), load(for:), delete(for:), hasGeminiAPIKey, geminiAPIKey.
ServingConverter — Pure-value struct encapsulating all serving-unit conversion math. 4-strategy factorFor(_:) (ServingSize tables → direct mapping → chain → SI bridge), availableUnits, and scaledCalories/Protein/Carbs/Fat. Used by both EditEntryView and LogFoodSheet to eliminate duplicate conversion logic.
HealthKitService — Opt-in Apple Health writes (one HKQuantitySample per macro). Reads activeEnergyBurned.- OpenFoodFactsService — Text search and barcode lookup against the Open Food Facts REST API. Search uses search.openfoodfacts.org (Elasticsearch-backed, the v1 CGI endpoint returns 503). Barcode lookup uses world.openfoodfacts.org/api/v2/product/{code}. Batch-fetches full product details after search using withTaskGroup. OFFProduct model stores full nutrition. lookupBarcode() manages UI state (isLoading/errorMessage); internal fetchProductByBarcode() is stateless for batch use. Used by both OpenFoodFactsSearchView (text search) and ScanCaptureView (barcode camera scan).- UserGoals — Daily targets for cal/protein/carbs/fat, persisted in UserDefaults.
View Map
See references/views.md for detailed view hierarchy and notable patterns.
Scan Flow (Core Value Prop)
User taps Scan → CameraController (AVCaptureSession) → JPEG
→ Prompt overlay: food photo shows text input, label scan skips prompt
→ ScanService.scan(image, mode) → loads API key from KeychainService
→ Builds JSON request with base64-encoded image + prompt
→ POST to https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}
→ ContinuousClock measures full round-trip duration
→ Label mode: gemini-3.1-flash-lite-preview (fast, MINIMAL thinking)
→ Food photo mode: gemini-3.1-pro-preview (HIGH thinking for estimation)
→ Fallback on 500/503: gemini-2.5-flash (labels) / gemini-2.5-pro (food photos)
→ Parses last text part (skips thinking parts) → JSON → GeminiNutritionResponse
→ GeminiNutritionResponse → NutritionEntry (NOT inserted yet)
→ Entry gets scanDurationMs set from ContinuousClock measurement
→ ScanResultCard (editable, shows duration badge) → User taps "Add to Journal"
→ NutritionStore.log(entry, to: date) → SwiftData insert (CloudKit syncs automatically)
→ Auto-creates SavedFood in Food Bank
→ HealthKitService.write(entry) if enabled
Barcode Scan Flow
User taps "Scan Barcode" card → CameraController (AVCaptureSession) → JPEG
→ promptOverlay: "Look Up" button (no text prompt for barcode mode)
→ Vision VNDetectBarcodesRequest detects barcode from photo
→ OpenFoodFactsService.lookupBarcode(barcodeValue)
→ GET https://world.openfoodfacts.org/api/v2/product/{code}?fields=...
→ OFFProduct → ManualEntryView(defaultDate:, prefillProduct:)
→ User reviews/edits pre-filled nutrition data → saves to journal
Open Food Facts Search Flow
User taps "Search Open Food Facts" in Food Bank "+" menu
→ OpenFoodFactsSearchView (searchable, Enter-only via .onSubmit)
→ GET https://search.openfoodfacts.org/search?q={query}&fields=...&page_size=25
→ Returns barcodes → batch-fetch full products via withTaskGroup
→ OFFProductRow (styled like SavedFoodRowView — macros, calories, serving)
→ Tap row → ManualEntryView(defaultDate:, prefillProduct: product)
→ User reviews/edits pre-filled data → saves to journal
CloudKit Sync Architecture (app-store branch)
iOS App (SwiftData + CloudKit)
←→ iCloud Private Database (automatic, free, multi-device)
→ ScanService → Direct Gemini REST API (BYOK, zero server dependency)
→ API key stored in iOS Keychain via KeychainService
Strategy: SwiftData's ModelConfiguration(cloudKitDatabase: .private("iCloud.k3vnc.OpenFoodJournal")) handles all sync automatically. No sync code in the iOS app. Zero server maintenance for data operations.
CloudKit requirements enforced on all models:
- All stored properties have defaults (including fully qualified enum defaults:
MealType.snack not .snack)
- No
@Attribute(.unique) (CloudKit can't enforce uniqueness)
- Relationships are optional (
var entries: [NutritionEntry]? = [])
- No
.deny delete rules
Entitlements: OpenFoodJournal.entitlements includes iCloud (CloudKit), Push Notifications (aps-environment), background mode (remote-notification).
Data migration: TursoMigrationView was deleted — CloudKit replaced Turso and the migration tool is dead code. All data now syncs via CloudKit Private Database automatically.
Server: Express.js on Render — exists in repo for main branch usage but is NOT used by the app-store branch at all. The iOS app calls Gemini REST API directly (BYOK). No server dependency.
Turso migration pattern (server/db.js): Use PRAGMA table_info(table_name) to check existing columns before running ALTER TABLE ... ADD COLUMN. Idempotent on re-deploy. Example:
const info = await db.execute("PRAGMA table_info(nutrition_entries)");
const cols = info.rows.map(r => r.name);
if (!cols.includes("serving_type")) {
await db.execute("ALTER TABLE nutrition_entries ADD COLUMN serving_type TEXT");
// ...
}
Server tables: daily_logs, nutrition_entries, saved_foods, tracked_containers, user_goals — schema in server/db.js.
API endpoints (mounted at /api):
GET /api/sync?since=ISO_TIMESTAMP — Full or incremental data pull
POST/GET/PUT/DELETE /api/entries, /api/foods, /api/containers
GET/PUT /api/goals
- Entries auto-create their parent
daily_log on POST
Sync integration points — CloudKit sync is fully automatic. No per-view sync calls needed. All SwiftData mutations are automatically pushed to CloudKit.
Environment vars (Render): TURSO_DATABASE_URL, TURSO_AUTH_TOKEN (falls back to file:local.db for dev)
Known Gotchas
xcodebuild is stricter than Xcode IDE — missing import SwiftData may compile in previews but fail in CLI builds. Always verify with xcodebuild.
.easeInOut is a static property — don't write .easeInOut(value:). The value: belongs to .animation(_:value:).
- Ternary type mismatch —
.primary is HierarchicalShapeStyle, .orange is Color. Use Color.primary to unify.
@ObservationIgnored on @AppStorage — required in UserGoals to avoid double-wrapper conflict. Every new @AppStorage property needs it.
private enum across structs — private scopes to the type, not the file. Use fileprivate for shared-file enums.
ModelContainer(for:configurations:) — not schema:. The Schema wrapper is only for migrations.
- Swift named parameters must be in declaration order — adding a new
init parameter doesn't let you put it anywhere in call sites. Reorder both the declaration and all call sites to match. Build will catch mismatches.
PRAGMA table_info() not IF NOT EXISTS for ALTER TABLE — SQLite doesn't support ALTER TABLE ADD COLUMN IF NOT EXISTS. Use PRAGMA to check first.
servingQuantity/servingUnit must be set after scaling in LogFoodSheet — toNutritionEntry() copies the food template's serving values; after scaling macros in logButton, write entry.servingQuantity = quantity and entry.servingUnit = selectedUnit so EditEntryView opens with the correct baseline.
.swipeActions is silently ignored outside a List — it does nothing in LazyVStack, ScrollView, or VStack. If swipe actions don't fire, check that the ForEach is inside a List. The modifier compiles without error regardless of container.
- Smooth tappable List rows with swipeActions: use
Button + .buttonStyle(.plain) — Default Button style causes ~150ms tap disambiguation delay. onTapGesture + contentShape(Rectangle()) eliminates tap delay but makes swipes choppy (TapGesture blocks swipe tracking). The correct pattern is Button { } label: { YourRowView() }.buttonStyle(.plain) — UIKit-optimized for List swipe coexistence, smooth on both taps and swipes.
- ZStack gesture priority — in a
ZStack, the last (top) view receives gestures first. Put a Color.clear.contentShape(Rectangle()).onTapGesture { dismiss() } layer before the overlay content to create an outside-tap dismiss. Views on top intercept taps; the clear layer catches everything that falls through.
Color.clear needs .contentShape(Rectangle()) — transparent views have no hit-test area by default. Without contentShape, taps pass through as if the view doesn't exist.
@Model enum defaults must be fully qualified — var mealType: MealType = .snack fails during macro expansion. Use MealType.snack. The error message is unhelpful (just says macro expansion failed).
- CloudKit optional relationships need
safeEntries pattern — var entries: [NutritionEntry]? = [] requires unwrapping everywhere. Add var safeEntries: [NutritionEntry] { entries ?? [] } and use that for reads. Use log.entries?.append(entry) for writes.
KnownMicronutrient.Category cases are .vitamin/.mineral — not .vitamins/.minerals. The enum raw values are plural ("Vitamins"/"Minerals") but the Swift case names are singular.
What's New Sheet Pattern
WhatsNewSheet is version-gated via @AppStorage("lastSeenVersion") in ContentView. On appear, if lastSeenVersion != currentVersion (from CFBundleShortVersionString), the sheet is presented. On dismiss, lastSeenVersion is updated. To add features for a new version, edit WhatsNewSheet.swift and bump the version header text. Each feature is a FeatureRow(icon:, color:, title:, description:).
v1.1 features: Open Food Facts search, barcode scanning, faster food photo scans (lite model default), reorderable nutrient rings, nutrition history navigation (day/week/month with date picker).
Entitlements (app-store branch)
Already configured in OpenFoodJournal.entitlements:
com.apple.developer.icloud-services: CloudKit
com.apple.developer.icloud-container-identifiers: iCloud.k3vnc.OpenFoodJournal
aps-environment: development (auto-switches to production on Archive)
UIBackgroundModes: remote-notification
Still needed (must add to entitlements):
com.apple.developer.healthkit (HealthKit usage compiles but entitlement missing)
Still needed (provisioning profile):
- Enable iCloud + Push Notifications capabilities in Apple Developer portal
- Regenerate provisioning profile
Branches
main — Original Turso sync architecture. SyncService.swift present, all views have fire-and-forget sync Tasks. Used for developer's personal Turso instance.
app-store — CloudKit sync, no Turso dependency. SyncService.swift deleted, all sync Tasks removed. TursoMigrationView for one-time data import. This is the App Store submission branch.
Conventions
- Comments: Explain why, not what. Entry-level devs should understand each function's purpose.
- File creation: Build large files in small chunks to avoid network errors.
- Retrospectives: Live in
docs/. Update when later fixes change the story.
- Skills: This file is the project skill. Update it when architecture or requirements change.
- Commits: Descriptive messages. Push after every significant change.
DailyLogView container: Uses a List (not ScrollView+LazyVStack) with .listStyle(.plain) + .scrollContentBackground(.hidden). WeeklyCalendarStrip and MacroSummaryBar are plain List rows with listRowBackground(Color.clear) + listRowSeparator(.hidden). Meal sections use MealSectionView which returns a Section{} that becomes a proper sticky List section header in a List context. All swipe actions are on the MealSectionView Button wrapper (not on EntryRowView).
RadialMenuButton: Option bubbles support direct .onTapGesture (as well as drag-to-select). A Color.clear.contentShape(Rectangle()).ignoresSafeArea() layer behind GlassEffectContainer dismisses the menu when tapping outside. The layer is only inserted into the ZStack when isOpen == true. Option label text has a subtle drop shadow for legibility over light/glass backgrounds.
Swipe mappings:
FoodBankView row: trailing (swipe left) = Edit (blue) + Delete (red, no full-swipe); leading (swipe right) = Add to journal (green)
MealSectionView row: trailing (swipe left) = Delete; leading (swipe right) = Edit — both on the outer Button wrapper (never on EntryRowView to avoid double-registration gesture lag)
FoodNutrientBreakdownView: Inverse of NutrientBreakdownView — shows all nutrients (macros + micros) for a specific food across the selected period. Accessed by tapping a food row in NutrientBreakdownView's "By Food" section.
EditEntryView: Has full serving-mappings section (same as LogFoodSheet). Uses shared AddServingMappingSheet (defined in LogFoodSheet.swift, internal not private). addMapping() calls nutritionStore.saveEntry(entry).
Current State (Last Updated: 2026-04-02)
- Branch:
app-store — CloudKit migration complete, all Turso sync code removed
- App structure complete: all models, services, and views implemented
- 4-tab layout: Journal, Food Bank, History, Settings (Containers accessed via RadialMenuButton)
- Builds successfully with
xcodebuild (generic/platform=iOS, signing disabled)
- SwiftData + CloudKit Private Database for data persistence and sync
- Render proxy deployed at
openfoodjournal.onrender.com (Gemini scan proxy only)
- Food Bank: save foods from scan/manual entry, browse/search/sort, log to journal
- Open Food Facts integration: search 4M+ products, add to journal/food bank, debounced search, per-serving nutrition
- Food Bank "+" toolbar menu: Search Open Food Facts, Manual Entry (replaces empty-state-only guidance)
- Settings: OFF contribute toggle (
off.contributeEnabled, default off) in Integrations section
- Container Tracking: create from Food Bank food, enter start weight, complete with final weight
- Serving Mappings: per-food unit conversions, editable in EditEntryView
- NutritionDetailView macro cards: circular progress rings (not linear ProgressView), showing value inside ring + percentage below
- FoodNutrientBreakdownView: inverse of NutrientBreakdownView — food → all nutrients. NavigationLink from NutrientBreakdownView "By Food" rows
- LogFoodSheet editable micronutrients: collapsible DisclosureGroup with editable text fields for each micronutrient, values applied at log time
- Radial menu text shadow: option labels have
.shadow() for legibility over glass
- Swipe gesture lag fix: swipe actions consolidated on MealSectionView Button wrapper (removed from EntryRowView), SavedFoodRowView has
.contentShape(Rectangle()), EntryRowView uses static DateFormatter
- WeeklyCalendarStrip: horizontally scrollable week strip with momentum snapping
- Comprehensive micronutrient tracking: 30 FDA nutrients with daily values
- Entitlements configured: iCloud (CloudKit), Push Notifications, Camera, HealthKit descriptions
- BYOK Gemini integration: direct REST API calls, no server proxy needed
- KeychainService for secure API key storage
- Onboarding flow: 6 pages (Welcome → API Key → Goals → Camera → Radial Menu Tutorial → HealthKit)
- RadialMenuDemo: pure SwiftUI phase-based animation teaching press-and-drag gesture (in onboarding page 4)
- Settings: API key management section (save/delete/change key), "Show Onboarding" replay button, "Sources & Disclaimers" link
- App Store Guideline 1.4.1 compliance: HealthDisclaimerView with FDA citations (21 CFR §101.9), AI estimation disclaimers, Atwater system citation, and general medical disclaimer. Inline citations on NutritionDetailView, ScanResultCard, and GoalsEditorView.
- App Store audit complete: HealthKit entitlement, Privacy Policy, PrivacyInfo.xcprivacy, AGPL→MIT licensing
- sourceImage removed from NutritionEntry and SavedFood (was stored but never displayed)
- TursoMigrationView deleted — CloudKit replaced Turso, migration tool is dead code
- No unit tests beyond Xcode template stubs
App Store Submission Notes
First submission rejected (Guideline 1.4.1 — Physical Harm): App provided health/nutrition data without citations. Fixed by adding HealthDisclaimerView (Settings → Sources & Disclaimers) with FDA Daily Values links, Atwater system reference, AI estimation disclaimer, and general medical disclaimer. Inline citations added to NutritionDetailView, ScanResultCard, and GoalsEditorView.
Other audit findings to address before next submission:
- README.md describes
main branch architecture (Turso/Express), not app-store branch. Update README or add branch-specific note.
- Reviewer notes previously contained false HealthKit claim ("data is never read") — corrected to list all read/write types.
server/ directory and render.yaml are dead code on app-store branch — reviewers clicking the GitHub link may be confused.
- AGPL-3.0 license has App Store exception in LICENSE_NOTICE.md — Apple may or may not flag this.
- Privacy policy is web-only (GitHub link) — fails offline. Consider embedding a copy in-app.
Planned Work (Backlog)
UX / Feature Improvements
- Weekly/monthly view should display daily averages — In
NutritionDetailView, when selectedPeriod is .weekly or .monthly, macroTotals currently accumulates totals. For multi-day periods, display the daily average (total ÷ days in period) so "14,000 kcal this week" becomes "2,000 kcal/day avg". The period picker label should reflect this ("Weekly Avg", "Monthly Avg"). Same applies to the comparisonCard values in HistoryView.weekComparisonSection (already divides by 7 — verify .monthly path does the same).
Open Food Facts Integration
- Open Food Facts (OFF) is a free, open database of 4M+ food products. Integrated into the Food Bank for searching and adding foods without scanning.
- Service:
OpenFoodFactsService — @Observable @MainActor service for OFF REST API calls (text search + barcode lookup). Injected via .environment() from MacrosApp.
- API endpoints used:
- Text search:
GET https://search.openfoodfacts.org/search?q={query}&fields=product_name,brands,code&page_size=25 — dedicated Elasticsearch-backed search service (the legacy v1 CGI endpoint returns 503)
- Barcode/product lookup (v2):
GET https://world.openfoodfacts.org/api/v2/product/{barcode}?fields=... — full nutrition data
- Two-step flow: Search returns lightweight hits (name, brand, code via
OFFSearchHit); tapping a result fetches full nutrition via barcode lookup → OFFProduct
- Fields requested (for product detail):
product_name,brands,nutriments,serving_size,serving_quantity,code
- Nutriments mapping:
energy-kcal_100g → calories, proteins_100g → protein, carbohydrates_100g → carbs, fat_100g → fat, plus micronutrients (fiber, sugar, sodium, etc.)
- User-Agent:
OpenFoodJournal/1.0 (openfoodjournal@example.com) — required by OFF API policy
- Rate limits: 10 req/min for search, 100 req/min for product reads
- No auth required for reads; writes (contribute) require OFF account credentials
- UI access points:
- FoodBankView toolbar: "+" button (leading of sort button) → Menu with 3 options: Scan, Manual Entry, Search Open Food Facts
- Scan and Manual Entry reuse existing sheets (
ScanView via DailyLogSheet, ManualEntryView)
- Search Open Food Facts opens
OpenFoodFactsSearchView — debounced text input, paginated results, tap to review details
- Result action: Same pattern as ManualEntryView — "Add to Journal" or "Add to Journal & Food Bank" via toolbar Menu
- Settings:
@AppStorage("off.contributeEnabled") toggle (default: false) in SettingsView. When enabled, scanned nutrition data is submitted back to OFF after the user logs it. Write API uses POST /cgi/product_jqm2.pl with user credentials.
- Data flow: OFF search result →
OFFProduct struct (Codable) → converted to NutritionEntry / SavedFood with scanMode: .manual and brand preserved. Nutrition values from OFF are per-100g; conversion to serving-based values uses serving_size field when available.
- No SPM dependency: Direct
URLSession calls, no OFF Swift SDK imported (keeps zero-dependency architecture).
1---2name: openfoodjournal3description: Provides living project knowledge for the OpenFoodJournal iOS app, including architecture, data models, service contracts, and conventions, to be consulted before any work on the project.4---56# OpenFoodJournal — Project Knowledge78This is the single source of truth for any LLM agent working on this project. Read this before writing code. Update it when things change.910## Quick Facts1112| Key | Value |13|-----|-------|14| Platform | iOS 26.2+ (iPhone) |15| UI Framework | SwiftUI + Liquid Glass (no `#available` gating needed) |16| Data Layer | SwiftData (`@Model`) + CloudKit Private Database (`iCloud.k3vnc.OpenFoodJournal`) |17| State Pattern | `@Observable` + `@Environment` injection (no singletons) |18| Bundle ID | `k3vnc.OpenFoodJournal` |19| Build System | Xcode (xcodebuild), no SPM dependencies |20| AI Backend | Direct Gemini REST API (BYOK — user provides own API key, stored in Keychain) |21| App Entry | `MacrosApp` in `OpenFoodJournalApp.swift` |2223## Architecture Overview2425```26MacrosApp (creates ModelContainer w/ CloudKit + 4 @Observable services)27 └─ ContentView (4-tab TabView)28 ├─ Journal tab → DailyLogView (WeeklyCalendarStrip, macro summary, meal sections, RadialMenuButton)29 ├─ Food Bank tab → FoodBankView (searchable, sortable saved food list, swipe-to-edit, "+" menu: Scan/Manual/Search OFF)30 ├─ History tab → HistoryView (CalendarGridView with progress rings, MacroChartView, macro cards → NutritionDetailView)31 └─ Settings tab → SettingsView (goals, health, data export)32```3334**Radial FAB**: DailyLogView uses `RadialMenuButton` — a "+" icon at bottom center that fans out Scan / Manual / Containers / Food Bank in an upper semicircle (210°–330°). Supports tap-to-toggle and drag-to-action. Containers are accessed from here instead of a separate tab.3536**Service injection**: All services (`NutritionStore`, `ScanService`, `HealthKitService`, `UserGoals`, `OpenFoodFactsService`) are created in `MacrosApp.init()` and passed via `.environment()`. Views consume them with `@Environment(ServiceType.self)`. `SyncService` was removed — CloudKit handles sync natively via `ModelConfiguration(cloudKitDatabase:)`.3738**Sheet management**: `DailyLogView` uses a single `DailyLogSheet` enum with `.sheet(item:)` — never multiple booleans.3940## Data Models4142See [references/models.md](references/models.md) for full property lists.4344- **`DailyLog`** — `@Model`, keyed by `date` normalized to midnight (no `@Attribute(.unique)` — CloudKit can't enforce uniqueness; app-level dedup in `fetchOrCreateLog(for:)`). Owns `[NutritionEntry]?` (optional for CloudKit) via cascade delete. Uses `safeEntries` computed property for reads.45- **`NutritionEntry`** — `@Model`, stores core macros (cal/protein/carbs/fat) + dynamic `micronutrients: [String: MicronutrientValue]` + brand/serving/servingCount/servingQuantity/servingUnit/servingMappings. `@Attribute(.externalStorage)` on `sourceImage`.46- **`SavedFood`** — `@Model`, reusable food template in Food Bank. Same fields as NutritionEntry minus meal/log context. Includes `lastUsedAt: Date` (defaults to `createdAt`) for "Last Used" sorting. Created from entries, manual input, or directly from ManualEntryView's "Add to Journal & Food Bank" action.47- **`TrackedContainer`** — `@Model`, weight-based container tracking. Snapshots food nutrition at creation time. Start weight → final weight → derived consumption via `consumedServings` math.48- **`UserGoals`** — `@Observable @MainActor`, uses `@ObservationIgnored @AppStorage` for each goal property to avoid property-wrapper conflicts.49- **`Preferences`** — `@Model`, singleton row for UI customization. Stores `ringSlot1..5` (nutrient IDs for MacroSummaryBar configurable rings). `Preferences.current(in:)` static factory fetches-or-creates the singleton. Synced to Turso via `GET/PUT /api/preferences`. Added to `ModelContainer` in app init. Sheets use `@Bindable var prefs: Preferences` for direct binding.50- **`MealType`** — enum: `.breakfast`, `.lunch`, `.dinner`, `.snack`51- **`ScanMode`** — enum: `.label`, `.foodPhoto`, `.barcode`, `.manual`52- **`ServingSize`** — enum: `.mass(grams:)`, `.volume(ml:)`, `.both(grams:ml:)`. Stores canonical SI values. Has `availableUnits: [String]` (dimension-appropriate unit list), `convert(_:from:to:) -> Double?` (handles same-dimension and cross-dimension via density for `.both`). Static tables: `massConversions` (g/oz/kg/lb), `volumeConversions` (mL/cup/tbsp/tsp/fl oz/L). `type: String` returns "mass"/"volume"/"both" for JSON serialization.53- **`ServingMapping`** — Codable struct with `from: ServingAmount` / `to: ServingAmount` for per-food unit conversions (e.g. 1 cup = 244g). Legacy field kept alongside `ServingSize`.54- **`MicronutrientValue`** — Codable struct with `value: Double` / `unit: String` for dynamic micronutrient storage5556## Services5758See [references/services.md](references/services.md) for full API contracts.5960- **`NutritionStore`** — SwiftData CRUD. `log()`, `fetchLog()`, `fetchLogs()`, `delete()`, `saveEntry()`, `exportCSV()`. Pure local operations — CloudKit sync is handled automatically by SwiftData's `ModelConfiguration(cloudKitDatabase:)`. No sync service reference, no fire-and-forget Tasks.61- **`ScanService`** — Resizes images to max 2000px (UIGraphicsImageRenderer) then JPEG 0.90 before direct Gemini REST API call (`generativelanguage.googleapis.com`) → `NutritionEntry` (not yet inserted). Uses `GeminiModelConfig` static configs: `.labelScan` (gemini-3.1-flash-lite-preview, MINIMAL thinking) and `.foodPhotoScan` (gemini-3.1-pro-preview, HIGH thinking). Includes automatic fallback to gemini-2.5-flash/pro on 500/503 errors. Loads API key from `KeychainService`. User reviews in `ScanResultCard` before committing. Logs scan duration via `ContinuousClock` and stores it on `NutritionEntry.scanDurationMs`.62- **`KeychainService`** — Static helper for secure Keychain storage (Security framework). Stores Gemini API key under service `k3vnc.OpenFoodJournal`, account `gemini-api-key`. Methods: `save(_:for:)`, `load(for:)`, `delete(for:)`, `hasGeminiAPIKey`, `geminiAPIKey`.63- **`ServingConverter`** — Pure-value struct encapsulating all serving-unit conversion math. 4-strategy `factorFor(_:)` (ServingSize tables → direct mapping → chain → SI bridge), `availableUnits`, and `scaledCalories/Protein/Carbs/Fat`. Used by both `EditEntryView` and `LogFoodSheet` to eliminate duplicate conversion logic.64- **`HealthKitService`** — Opt-in Apple Health writes (one `HKQuantitySample` per macro). Reads `activeEnergyBurned`.- **`OpenFoodFactsService`** — Text search and barcode lookup against the Open Food Facts REST API. Search uses `search.openfoodfacts.org` (Elasticsearch-backed, the v1 CGI endpoint returns 503). Barcode lookup uses `world.openfoodfacts.org/api/v2/product/{code}`. Batch-fetches full product details after search using `withTaskGroup`. `OFFProduct` model stores full nutrition. `lookupBarcode()` manages UI state (isLoading/errorMessage); internal `fetchProductByBarcode()` is stateless for batch use. Used by both `OpenFoodFactsSearchView` (text search) and `ScanCaptureView` (barcode camera scan).- **`UserGoals`** — Daily targets for cal/protein/carbs/fat, persisted in UserDefaults.6566## View Map6768See [references/views.md](references/views.md) for detailed view hierarchy and notable patterns.6970## Scan Flow (Core Value Prop)7172```73User taps Scan → CameraController (AVCaptureSession) → JPEG74 → Prompt overlay: food photo shows text input, label scan skips prompt75 → ScanService.scan(image, mode) → loads API key from KeychainService76 → Builds JSON request with base64-encoded image + prompt77 → POST to https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}78 → ContinuousClock measures full round-trip duration79 → Label mode: gemini-3.1-flash-lite-preview (fast, MINIMAL thinking)80 → Food photo mode: gemini-3.1-pro-preview (HIGH thinking for estimation)81 → Fallback on 500/503: gemini-2.5-flash (labels) / gemini-2.5-pro (food photos)82 → Parses last text part (skips thinking parts) → JSON → GeminiNutritionResponse83 → GeminiNutritionResponse → NutritionEntry (NOT inserted yet)84 → Entry gets scanDurationMs set from ContinuousClock measurement85 → ScanResultCard (editable, shows duration badge) → User taps "Add to Journal"86 → NutritionStore.log(entry, to: date) → SwiftData insert (CloudKit syncs automatically)87 → Auto-creates SavedFood in Food Bank88 → HealthKitService.write(entry) if enabled89```9091## Barcode Scan Flow9293```94User taps "Scan Barcode" card → CameraController (AVCaptureSession) → JPEG95 → promptOverlay: "Look Up" button (no text prompt for barcode mode)96 → Vision VNDetectBarcodesRequest detects barcode from photo97 → OpenFoodFactsService.lookupBarcode(barcodeValue)98 → GET https://world.openfoodfacts.org/api/v2/product/{code}?fields=...99 → OFFProduct → ManualEntryView(defaultDate:, prefillProduct:)100 → User reviews/edits pre-filled nutrition data → saves to journal101```102103## Open Food Facts Search Flow104105```106User taps "Search Open Food Facts" in Food Bank "+" menu107 → OpenFoodFactsSearchView (searchable, Enter-only via .onSubmit)108 → GET https://search.openfoodfacts.org/search?q={query}&fields=...&page_size=25109 → Returns barcodes → batch-fetch full products via withTaskGroup110 → OFFProductRow (styled like SavedFoodRowView — macros, calories, serving)111 → Tap row → ManualEntryView(defaultDate:, prefillProduct: product)112 → User reviews/edits pre-filled data → saves to journal113```114115## CloudKit Sync Architecture (app-store branch)116117```118iOS App (SwiftData + CloudKit)119 ←→ iCloud Private Database (automatic, free, multi-device)120121 → ScanService → Direct Gemini REST API (BYOK, zero server dependency)122 → API key stored in iOS Keychain via KeychainService123```124125**Strategy**: SwiftData's `ModelConfiguration(cloudKitDatabase: .private("iCloud.k3vnc.OpenFoodJournal"))` handles all sync automatically. No sync code in the iOS app. Zero server maintenance for data operations.126127**CloudKit requirements enforced on all models**:128- All stored properties have defaults (including fully qualified enum defaults: `MealType.snack` not `.snack`)129- No `@Attribute(.unique)` (CloudKit can't enforce uniqueness)130- Relationships are optional (`var entries: [NutritionEntry]? = []`)131- No `.deny` delete rules132133**Entitlements**: `OpenFoodJournal.entitlements` includes iCloud (CloudKit), Push Notifications (aps-environment), background mode (remote-notification).134135**Data migration**: TursoMigrationView was deleted — CloudKit replaced Turso and the migration tool is dead code. All data now syncs via CloudKit Private Database automatically.136137**Server**: Express.js on Render — exists in repo for `main` branch usage but is NOT used by the `app-store` branch at all. The iOS app calls Gemini REST API directly (BYOK). No server dependency.138139**Turso migration pattern** (`server/db.js`): Use `PRAGMA table_info(table_name)` to check existing columns before running `ALTER TABLE ... ADD COLUMN`. Idempotent on re-deploy. Example:140```javascript141const info = await db.execute("PRAGMA table_info(nutrition_entries)");142const cols = info.rows.map(r => r.name);143if (!cols.includes("serving_type")) {144 await db.execute("ALTER TABLE nutrition_entries ADD COLUMN serving_type TEXT");145 // ...146}147```148149**Server tables**: `daily_logs`, `nutrition_entries`, `saved_foods`, `tracked_containers`, `user_goals` — schema in `server/db.js`.150151**API endpoints** (mounted at `/api`):152- `GET /api/sync?since=ISO_TIMESTAMP` — Full or incremental data pull153- `POST/GET/PUT/DELETE /api/entries`, `/api/foods`, `/api/containers`154- `GET/PUT /api/goals`155- Entries auto-create their parent `daily_log` on POST156157**Sync integration points** — CloudKit sync is fully automatic. No per-view sync calls needed. All SwiftData mutations are automatically pushed to CloudKit.158159**Environment vars** (Render): `TURSO_DATABASE_URL`, `TURSO_AUTH_TOKEN` (falls back to `file:local.db` for dev)160161## Known Gotchas1621631. **`xcodebuild` is stricter than Xcode IDE** — missing `import SwiftData` may compile in previews but fail in CLI builds. Always verify with `xcodebuild`.1642. **`.easeInOut` is a static property** — don't write `.easeInOut(value:)`. The `value:` belongs to `.animation(_:value:)`.1653. **Ternary type mismatch** — `.primary` is `HierarchicalShapeStyle`, `.orange` is `Color`. Use `Color.primary` to unify.1664. **`@ObservationIgnored` on `@AppStorage`** — required in `UserGoals` to avoid double-wrapper conflict. Every new `@AppStorage` property needs it.1675. **`private` enum across structs** — `private` scopes to the type, not the file. Use `fileprivate` for shared-file enums.1686. **`ModelContainer(for:configurations:)`** — not `schema:`. The `Schema` wrapper is only for migrations.1697. **Swift named parameters must be in declaration order** — adding a new `init` parameter doesn't let you put it anywhere in call sites. Reorder both the declaration *and* all call sites to match. Build will catch mismatches.1708. **`PRAGMA table_info()` not `IF NOT EXISTS` for ALTER TABLE** — SQLite doesn't support `ALTER TABLE ADD COLUMN IF NOT EXISTS`. Use PRAGMA to check first.1719. **`servingQuantity`/`servingUnit` must be set after scaling in LogFoodSheet** — `toNutritionEntry()` copies the food template's serving values; after scaling macros in logButton, write `entry.servingQuantity = quantity` and `entry.servingUnit = selectedUnit` so EditEntryView opens with the correct baseline.17210. **`.swipeActions` is silently ignored outside a `List`** — it does nothing in `LazyVStack`, `ScrollView`, or `VStack`. If swipe actions don't fire, check that the `ForEach` is inside a `List`. The modifier compiles without error regardless of container.17311. **Smooth tappable List rows with swipeActions: use `Button + .buttonStyle(.plain)`** — Default `Button` style causes ~150ms tap disambiguation delay. `onTapGesture + contentShape(Rectangle())` eliminates tap delay but makes swipes choppy (TapGesture blocks swipe tracking). The correct pattern is `Button { } label: { YourRowView() }.buttonStyle(.plain)` — UIKit-optimized for List swipe coexistence, smooth on both taps and swipes.17412. **ZStack gesture priority** — in a `ZStack`, the **last** (top) view receives gestures first. Put a `Color.clear.contentShape(Rectangle()).onTapGesture { dismiss() }` layer **before** the overlay content to create an outside-tap dismiss. Views on top intercept taps; the clear layer catches everything that falls through.17513. **`Color.clear` needs `.contentShape(Rectangle())`** — transparent views have no hit-test area by default. Without `contentShape`, taps pass through as if the view doesn't exist.17614. **`@Model` enum defaults must be fully qualified** — `var mealType: MealType = .snack` fails during macro expansion. Use `MealType.snack`. The error message is unhelpful (just says macro expansion failed).17715. **CloudKit optional relationships need `safeEntries` pattern** — `var entries: [NutritionEntry]? = []` requires unwrapping everywhere. Add `var safeEntries: [NutritionEntry] { entries ?? [] }` and use that for reads. Use `log.entries?.append(entry)` for writes.17816. **`KnownMicronutrient.Category` cases are `.vitamin`/`.mineral`** — not `.vitamins`/`.minerals`. The enum raw values are plural ("Vitamins"/"Minerals") but the Swift case names are singular.179180## What's New Sheet Pattern181182`WhatsNewSheet` is version-gated via `@AppStorage("lastSeenVersion")` in `ContentView`. On appear, if `lastSeenVersion != currentVersion` (from `CFBundleShortVersionString`), the sheet is presented. On dismiss, `lastSeenVersion` is updated. To add features for a new version, edit `WhatsNewSheet.swift` and bump the version header text. Each feature is a `FeatureRow(icon:, color:, title:, description:)`.183184**v1.1 features**: Open Food Facts search, barcode scanning, faster food photo scans (lite model default), reorderable nutrient rings, nutrition history navigation (day/week/month with date picker).185186## Entitlements (app-store branch)187188Already configured in `OpenFoodJournal.entitlements`:189- `com.apple.developer.icloud-services`: CloudKit190- `com.apple.developer.icloud-container-identifiers`: iCloud.k3vnc.OpenFoodJournal191- `aps-environment`: development (auto-switches to production on Archive)192- `UIBackgroundModes`: remote-notification193194**Still needed** (must add to entitlements):195- `com.apple.developer.healthkit` (HealthKit usage compiles but entitlement missing)196197**Still needed** (provisioning profile):198- Enable iCloud + Push Notifications capabilities in Apple Developer portal199- Regenerate provisioning profile200201## Branches202203- **`main`** — Original Turso sync architecture. `SyncService.swift` present, all views have fire-and-forget sync Tasks. Used for developer's personal Turso instance.204- **`app-store`** — CloudKit sync, no Turso dependency. `SyncService.swift` deleted, all sync Tasks removed. `TursoMigrationView` for one-time data import. This is the App Store submission branch.205206## Conventions207208- **Comments**: Explain *why*, not *what*. Entry-level devs should understand each function's purpose.209- **File creation**: Build large files in small chunks to avoid network errors.210- **Retrospectives**: Live in `docs/`. Update when later fixes change the story.211- **Skills**: This file is the project skill. Update it when architecture or requirements change.212- **Commits**: Descriptive messages. Push after every significant change.213214**DailyLogView container**: Uses a `List` (not ScrollView+LazyVStack) with `.listStyle(.plain)` + `.scrollContentBackground(.hidden)`. `WeeklyCalendarStrip` and `MacroSummaryBar` are plain List rows with `listRowBackground(Color.clear)` + `listRowSeparator(.hidden)`. Meal sections use `MealSectionView` which returns a `Section{}` that becomes a proper sticky List section header in a List context. All swipe actions are on the `MealSectionView` Button wrapper (not on `EntryRowView`).215216**RadialMenuButton**: Option bubbles support direct `.onTapGesture` (as well as drag-to-select). A `Color.clear.contentShape(Rectangle()).ignoresSafeArea()` layer behind `GlassEffectContainer` dismisses the menu when tapping outside. The layer is only inserted into the ZStack when `isOpen == true`. Option label text has a subtle drop shadow for legibility over light/glass backgrounds.217218**Swipe mappings**:219- `FoodBankView` row: trailing (swipe left) = Edit (blue) + Delete (red, no full-swipe); leading (swipe right) = Add to journal (green)220- `MealSectionView` row: trailing (swipe left) = Delete; leading (swipe right) = Edit — both on the outer Button wrapper (never on `EntryRowView` to avoid double-registration gesture lag)221222**FoodNutrientBreakdownView**: Inverse of `NutrientBreakdownView` — shows all nutrients (macros + micros) for a specific food across the selected period. Accessed by tapping a food row in NutrientBreakdownView's "By Food" section.223224**EditEntryView**: Has full serving-mappings section (same as LogFoodSheet). Uses shared `AddServingMappingSheet` (defined in LogFoodSheet.swift, internal not private). `addMapping()` calls `nutritionStore.saveEntry(entry)`.225226## Current State (Last Updated: 2026-04-02)227228- **Branch: `app-store`** — CloudKit migration complete, all Turso sync code removed229- App structure complete: all models, services, and views implemented230- 4-tab layout: Journal, Food Bank, History, Settings (Containers accessed via RadialMenuButton)231- Builds successfully with `xcodebuild` (generic/platform=iOS, signing disabled)232- SwiftData + CloudKit Private Database for data persistence and sync233- Render proxy deployed at `openfoodjournal.onrender.com` (Gemini scan proxy only)234- Food Bank: save foods from scan/manual entry, browse/search/sort, log to journal235- **Open Food Facts integration**: search 4M+ products, add to journal/food bank, debounced search, per-serving nutrition236- **Food Bank "+" toolbar menu**: Search Open Food Facts, Manual Entry (replaces empty-state-only guidance)237- **Settings: OFF contribute toggle** (`off.contributeEnabled`, default off) in Integrations section238- Container Tracking: create from Food Bank food, enter start weight, complete with final weight239- Serving Mappings: per-food unit conversions, editable in EditEntryView240- **NutritionDetailView macro cards**: circular progress rings (not linear ProgressView), showing value inside ring + percentage below241- **FoodNutrientBreakdownView**: inverse of NutrientBreakdownView — food → all nutrients. NavigationLink from NutrientBreakdownView "By Food" rows242- **LogFoodSheet editable micronutrients**: collapsible DisclosureGroup with editable text fields for each micronutrient, values applied at log time243- **Radial menu text shadow**: option labels have `.shadow()` for legibility over glass244- **Swipe gesture lag fix**: swipe actions consolidated on MealSectionView Button wrapper (removed from EntryRowView), SavedFoodRowView has `.contentShape(Rectangle())`, EntryRowView uses static DateFormatter245- WeeklyCalendarStrip: horizontally scrollable week strip with momentum snapping246- Comprehensive micronutrient tracking: 30 FDA nutrients with daily values247- Entitlements configured: iCloud (CloudKit), Push Notifications, Camera, HealthKit descriptions248- BYOK Gemini integration: direct REST API calls, no server proxy needed249- KeychainService for secure API key storage250- Onboarding flow: 6 pages (Welcome → API Key → Goals → Camera → Radial Menu Tutorial → HealthKit)251- RadialMenuDemo: pure SwiftUI phase-based animation teaching press-and-drag gesture (in onboarding page 4)252- Settings: API key management section (save/delete/change key), "Show Onboarding" replay button, "Sources & Disclaimers" link253- **App Store Guideline 1.4.1 compliance**: HealthDisclaimerView with FDA citations (21 CFR §101.9), AI estimation disclaimers, Atwater system citation, and general medical disclaimer. Inline citations on NutritionDetailView, ScanResultCard, and GoalsEditorView.254- App Store audit complete: HealthKit entitlement, Privacy Policy, PrivacyInfo.xcprivacy, AGPL→MIT licensing255- sourceImage removed from NutritionEntry and SavedFood (was stored but never displayed)256- TursoMigrationView deleted — CloudKit replaced Turso, migration tool is dead code257- No unit tests beyond Xcode template stubs258259## App Store Submission Notes260261**First submission rejected (Guideline 1.4.1 — Physical Harm)**: App provided health/nutrition data without citations. Fixed by adding `HealthDisclaimerView` (Settings → Sources & Disclaimers) with FDA Daily Values links, Atwater system reference, AI estimation disclaimer, and general medical disclaimer. Inline citations added to NutritionDetailView, ScanResultCard, and GoalsEditorView.262263**Other audit findings to address before next submission:**264- README.md describes `main` branch architecture (Turso/Express), not `app-store` branch. Update README or add branch-specific note.265- Reviewer notes previously contained false HealthKit claim ("data is never read") — corrected to list all read/write types.266- `server/` directory and `render.yaml` are dead code on `app-store` branch — reviewers clicking the GitHub link may be confused.267- AGPL-3.0 license has App Store exception in LICENSE_NOTICE.md — Apple may or may not flag this.268- Privacy policy is web-only (GitHub link) — fails offline. Consider embedding a copy in-app.269270## Planned Work (Backlog)271272### UX / Feature Improvements273274- **Weekly/monthly view should display daily averages** — In `NutritionDetailView`, when `selectedPeriod` is `.weekly` or `.monthly`, `macroTotals` currently accumulates totals. For multi-day periods, display the **daily average** (total ÷ days in period) so "14,000 kcal this week" becomes "2,000 kcal/day avg". The period picker label should reflect this ("Weekly Avg", "Monthly Avg"). Same applies to the `comparisonCard` values in `HistoryView.weekComparisonSection` (already divides by 7 — verify `.monthly` path does the same).275276### Open Food Facts Integration277278- **Open Food Facts (OFF)** is a free, open database of 4M+ food products. Integrated into the Food Bank for searching and adding foods without scanning.279- **Service**: `OpenFoodFactsService` — `@Observable @MainActor` service for OFF REST API calls (text search + barcode lookup). Injected via `.environment()` from `MacrosApp`.280- **API endpoints used**:281 - **Text search**: `GET https://search.openfoodfacts.org/search?q={query}&fields=product_name,brands,code&page_size=25` — dedicated Elasticsearch-backed search service (the legacy v1 CGI endpoint returns 503)282 - **Barcode/product lookup (v2)**: `GET https://world.openfoodfacts.org/api/v2/product/{barcode}?fields=...` — full nutrition data283 - **Two-step flow**: Search returns lightweight hits (name, brand, code via `OFFSearchHit`); tapping a result fetches full nutrition via barcode lookup → `OFFProduct`284 - **Fields requested** (for product detail): `product_name,brands,nutriments,serving_size,serving_quantity,code`285 - **Nutriments mapping**: `energy-kcal_100g` → calories, `proteins_100g` → protein, `carbohydrates_100g` → carbs, `fat_100g` → fat, plus micronutrients (fiber, sugar, sodium, etc.)286 - **User-Agent**: `OpenFoodJournal/1.0 (openfoodjournal@example.com)` — required by OFF API policy287 - **Rate limits**: 10 req/min for search, 100 req/min for product reads288 - **No auth required** for reads; writes (contribute) require OFF account credentials289- **UI access points**:290 - **FoodBankView toolbar**: "+" button (leading of sort button) → Menu with 3 options: Scan, Manual Entry, Search Open Food Facts291 - **Scan** and **Manual Entry** reuse existing sheets (`ScanView` via DailyLogSheet, `ManualEntryView`)292 - **Search Open Food Facts** opens `OpenFoodFactsSearchView` — debounced text input, paginated results, tap to review details293 - **Result action**: Same pattern as ManualEntryView — "Add to Journal" or "Add to Journal & Food Bank" via toolbar Menu294- **Settings**: `@AppStorage("off.contributeEnabled")` toggle (default: `false`) in SettingsView. When enabled, scanned nutrition data is submitted back to OFF after the user logs it. Write API uses `POST /cgi/product_jqm2.pl` with user credentials.295- **Data flow**: OFF search result → `OFFProduct` struct (Codable) → converted to `NutritionEntry` / `SavedFood` with `scanMode: .manual` and brand preserved. Nutrition values from OFF are per-100g; conversion to serving-based values uses `serving_size` field when available.296- **No SPM dependency**: Direct `URLSession` calls, no OFF Swift SDK imported (keeps zero-dependency architecture).