# PlatePilot Menus — build spec

A healthy eating-out guide that shows verified, personalized menu recommendations before asking for an account or subscription.

## Project context

This spec describes an independent, alternative Android app you are building from scratch to compete with an existing incumbent app on Google Play — not a modification, clone, or reskin of the incumbent's own code, assets, or branding. Use the incumbent only as a market reference (via the report data below), and design working_name/package_id/store_listing/design_system so the result is clearly its own product.

## Incumbent app

- **Name:** MenuFit - Healthy Eating Out
- **Package id:** `com.myhealthymenu.app`
- **Google Play:** https://play.google.com/store/apps/details?id=com.myhealthymenu.app
- **appy.fyi report:** https://appy.fyi/report/com.myhealthymenu.app
- **Category:** Health & Fitness

## Overview

- **Working name:** PlatePilot Menus (trademark cleared: no)
- **Package id:** `fyi.appy.platepilotmenus`
- **Min / target SDK:** 26 / 35
- **Backend:** firebase
- **Estimated build time:** 7 weeks
- **Pricing:** subscription, $9.99 via `revenuecat`
- **Runtime AI:** yes (ai_gateway_llm): Answer a user menu question using only candidate menu rows retrieved from the verified local nutrition dataset. ≈ $0.002/call
- **Permissions:** `INTERNET`

## Non-goals (out of scope for v1)

- No claim to cover every restaurant in the world in v1; coverage is limited to the sourced top 30-50 launch chains.
- No forced paywall before the user completes preferences and sees real recommendations.
- No free-form AI menu generation; any AI answer must be grounded only in verified menu items already stored in the dataset.
- No medical diagnosis, treatment advice, or allergy-safety guarantee beyond filtering against sourced allergen fields.
- No grocery barcode scanning, meal logging, calorie diary, or home recipe planner in v1.

## Tech stack

- **Language / UI:** Kotlin, Jetpack Compose
- **Kotlin:** 2.0.21
- **Compose BOM:** 2024.10.01
- **Gradle:** 8.9

| Purpose | Gradle coordinate |
| --- | --- |
| Android core Kotlin extensions | `androidx.core:core-ktx:1.13.1` |
| Compose activity host | `androidx.activity:activity-compose:1.9.3` |
| Material 3 Compose UI components | `androidx.compose.material3:material3:1.3.1` |
| Compose Navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| Lifecycle-aware Compose ViewModels | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7` |
| Local relational nutrition dataset storage | `androidx.room:room-runtime:2.6.1` |
| Kotlin coroutine extensions for Room DAOs | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor | `androidx.room:room-compiler:2.6.1` |
| JSON parsing for bundled nutrition seed files and AI gateway responses | `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3` |
| Firebase Authentication for optional account creation after preview value is shown | `com.google.firebase:firebase-auth:23.1.0` |
| Firestore storage for user account entitlement metadata | `com.google.firebase:firebase-firestore:25.1.1` |
| RevenueCat subscription purchase, restore, and entitlement state | `com.revenuecat.purchases:purchases:8.9.0` |
| HTTPS calls to the AI gateway for grounded menu answers | `com.squareup.okhttp3:okhttp:4.12.0` |
| Kotlin coroutines for asynchronous Room, Firebase, RevenueCat, and network work | `org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0` |

## Design system

- **Primary color:** `#1B6E5C`
- **Background color:** `#FAF7F0`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `fork-knife` (regular weight)
- **Theme notes:** Use a clean food-and-health visual style with rounded Material 3 cards. Light theme background is #FAF7F0 with dark text #1F1F1F. Dark theme background is #121512, primary remains #1B6E5C, and cards use #1E241F. Verified nutrition values use primary green labels; unverified or missing values use amber #B26A00 warning labels.

## Screens

### Launch
- **Route:** `launch`
- **Purpose:** Initial routing screen that loads the local nutrition dataset and decides whether to show onboarding, recommendations, or an error.
- **Reached via:** app launch
- **Key UI elements:** App logo and name; Progress indicator while Room seed data is imported; Retry button if seed import fails; Short message that recommendations can be previewed without account or payment
- **States:** loading, error, ready

### Preference Onboarding
- **Route:** `onboarding`
- **Purpose:** Collects diet, allergy, macro, and unit preferences without requesting email, account creation, card details, or subscription.
- **Reached via:** Launch when no local UserPreference exists; tap Edit preferences on Recommendations; tap Edit preferences on Settings
- **Key UI elements:** Diet type selector with options: No specific diet, Vegetarian; Gluten avoidance toggle; Calorie target per meal numeric field; Protein minimum per meal numeric field; Unit system segmented control: US, Metric; Continue to recommendations button; Footer text: No account or payment needed to preview recommendations
- **States:** empty, editing, validation_error, saving

### Recommendations
- **Route:** `recommendations`
- **Purpose:** Shows personalized menu recommendations from verified chain data before the user is asked to subscribe.
- **Reached via:** Preference Onboarding after saving preferences; Launch when local UserPreference exists; tap Recommendations tab
- **Key UI elements:** Preference summary chip row; Recommended menu item card list; Verification badge on each item; Macro summary: calories, protein, carbs, fat; Allergen warning row; Restaurant filter dropdown; Edit preferences button; Optional unlock banner after several recommendations are visible
- **States:** loading, empty, error, populated, locked_after_preview_limit

### Restaurant Catalog
- **Route:** `restaurants`
- **Purpose:** Lets users browse launch-chain coverage and inspect whether each chain is verified and what country coverage it has.
- **Reached via:** tap Restaurants tab; tap restaurant filter on Recommendations
- **Key UI elements:** Search field; Chain list grouped by country; Verification status chip; Menu item count per chain; Source date label; Open chain button
- **States:** loading, empty, error, populated, search_no_results

### Menu Item Detail
- **Route:** `item/{itemId}`
- **Purpose:** Displays full nutrition, allergen, diet, and source details for a single menu item.
- **Reached via:** tap a recommendation card on Recommendations; tap a menu item from Restaurant Catalog; tap cited item in Grounded Menu Answer
- **Key UI elements:** Item name and chain name; Verified or not verified badge; Serving size in selected unit system; Calories and macro table; Allergen list including gluten flag; Vegetarian suitability label; Source URL text; Last updated date; Ask about this item button
- **States:** loading, error, populated, not_found

### Grounded Menu Answer
- **Route:** `ask`
- **Purpose:** Answers menu questions only by citing verified dataset rows, preventing made-up menu items.
- **Reached via:** tap Ask tab; tap Ask about this item on Menu Item Detail
- **Key UI elements:** Question text field; Restaurant scope selector; Ask button; AI answer card; Cited menu item chips; No verified match message; Retry button on network error
- **States:** empty, loading, error, answered, no_verified_match, subscription_required

### Paywall
- **Route:** `paywall`
- **Purpose:** Presents the $9.99/month subscription only after preview recommendations have been shown.
- **Reached via:** tap unlock banner on Recommendations; tap subscription_required message on Grounded Menu Answer; tap Manage subscription on Account
- **Key UI elements:** Subscription benefit list; $9.99/month price text; Subscribe button; Restore purchases button; Continue limited preview button; Terms and privacy links; Loading indicator during purchase or restore
- **States:** idle, purchasing, restoring, purchase_error, restore_no_purchase, entitled

### Account
- **Route:** `account`
- **Purpose:** Handles optional post-preview account creation, sign-in, entitlement display, and restore access.
- **Reached via:** tap Account tab; Paywall after successful purchase when no account is linked
- **Key UI elements:** Email field; Password field; Create account button; Sign in button; Skip account button for preview users; Current subscription status; Restore purchases button; Sign out button
- **States:** signed_out, creating_account, signing_in, auth_error, signed_in_no_subscription, signed_in_entitled, restoring

### Settings
- **Route:** `settings`
- **Purpose:** Lets users change units and dietary preferences and view health/privacy disclaimers.
- **Reached via:** tap Settings tab
- **Key UI elements:** Unit system segmented control: US, Metric; Edit dietary preferences row; Privacy policy link; Health and allergy disclaimer text; Reset local preferences button; Dataset source status row
- **States:** loading, populated, saving, error

### Data Sources
- **Route:** `sources/{chainId}`
- **Purpose:** Shows the published nutrition source behind a chain so users can distinguish verified data from missing or unverified values.
- **Reached via:** tap source label on Menu Item Detail; tap source status on Restaurant Catalog; tap Dataset source status row on Settings
- **Key UI elements:** Chain name; Source URL; Source date; Verified item count; Unverified item count; Open source in browser button
- **States:** loading, error, populated, not_found

## Data model

### Chain (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; stable slug from bundled dataset |
| name | `String` | display name from source dataset |
| countryCode | `String` | ISO 3166-1 alpha-2 launch coverage code, e.g. US |
| category | `String` | restaurant category such as coffee, burger, sandwich, casual |
| sourceUrl | `String` | published nutrition PDF or menu page URL |
| sourceDateEpochDay | `Long` | date the source was last checked |
| verified | `Boolean` | true only when data came from the chain's own published nutrition source |

### MenuItem (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; stable slug from chain id plus item name |
| chainId | `String` | foreign key to Chain.id |
| name | `String` | menu item display name |
| category | `String` | menu section such as breakfast, drink, entree, side |
| servingSizeUs | `String` | serving size as published for US display, nullable when source omits it |
| servingSizeMetric | `String` | metric serving size display; sourced directly or converted from US value when possible |
| caloriesKcal | `Int` | calories from source; -1 when unavailable |
| proteinG | `Double` | grams from source; -1.0 when unavailable |
| carbsG | `Double` | grams from source; -1.0 when unavailable |
| fatG | `Double` | grams from source; -1.0 when unavailable |
| sodiumMg | `Double` | milligrams from source; -1.0 when unavailable |
| isVegetarian | `Boolean` | true only when source or deterministic ingredient/allergen rules mark the item vegetarian |
| containsGluten | `Boolean` | true when sourced allergen data includes wheat/gluten |
| allergensCsv | `String` | comma-separated sourced allergen names; empty string if source provides none |
| verified | `Boolean` | true only when nutrition and allergen values are source-backed |
| sourceUrl | `String` | item-level or chain-level source URL |
| updatedAtEpochMillis | `Long` | time this row was generated from source dataset |

### UserPreference (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key; always 1 for the current local preview user |
| dietType | `String` | allowed values: none, vegetarian |
| avoidGluten | `Boolean` | true excludes MenuItem.containsGluten rows |
| calorieTargetPerMealKcal | `Int` | nullable; positive values filter and rank around this target |
| proteinMinG | `Double` | nullable; positive values boost items at or above this protein amount |
| unitSystem | `String` | allowed values: us, metric |
| previewRecommendationsShown | `Int` | count of recommendation cards shown before subscription prompt |
| createdAtEpochMillis | `Long` |  |
| updatedAtEpochMillis | `Long` |  |

### AppUser (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| uid | `String` | document id; Firebase Auth uid |
| email | `String` | nullable until user creates an account after preview |
| revenueCatAppUserId | `String` | same as Firebase Auth uid after account linking |
| subscriptionActive | `Boolean` | mirrors RevenueCat entitlement state for UI continuity |
| productId | `String` | nullable; monthly subscription product when active |
| currentPeriodEndEpochMillis | `Long` | nullable; end of current paid period when RevenueCat provides it |
| lastRestoreAtEpochMillis | `Long` | nullable; last successful restore attempt |

## Features

### Value-first onboarding and recommendation preview

Collects only local dietary preferences and immediately shows several personalized recommendations before any account, email, card, or subscription step.

- **Answers complaint:** Payment before value

- **Screens:** Launch, Preference Onboarding, Recommendations, Paywall

- **Estimated hours:** 40

**Implementation notes:** On first launch, import bundled nutrition JSON assets into Room if Chain count is zero, then route to onboarding. Store onboarding answers in the single UserPreference row with id=1. Do not initialize Firebase Auth UI, RevenueCat paywall UI, or any email field on Launch or Preference Onboarding. After saving preferences, query MenuItem rows through the recommendation engine and render at least 5 cards when matches exist. Increment UserPreference.previewRecommendationsShown only when a recommendation card becomes visible; show the Paywall entry banner only after 5 visible cards or when the user taps a locked premium action.

**Acceptance criteria:**
- A fresh install reaches Recommendation cards without entering email, password, card details, or tapping Subscribe.
- The first Paywall route navigation cannot occur until at least 5 recommendation cards have been rendered or the user explicitly taps a premium action.
- Preference Onboarding validates calorie target as empty or 1-5000 and protein minimum as empty or 0-300 before saving.
- If no menu item matches the preferences, Recommendations shows an empty state with Edit preferences and Restaurants actions, not a paywall.

### Verified sourced nutrition catalog

Ships a local catalog for the launch chains using published chain nutrition PDFs or pages and labels unavailable or unverified values instead of guessing.

- **Answers complaint:** Data accuracy and coverage

- **Screens:** Launch, Restaurant Catalog, Menu Item Detail, Data Sources, Recommendations

- **Estimated hours:** 80

**Implementation notes:** Bundle a versioned asset file at app/src/main/assets/nutrition_seed_v1.json containing Chain and MenuItem records for the top 30-50 launch chains described by the report workstream. On app start, parse the JSON with kotlinx.serialization and upsert into Room inside a transaction keyed by MenuItem.id. Each record must include sourceUrl, sourceDateEpochDay, verified, and nutrient fields; when a source omits a nutrient, store -1 or -1.0 and render 'Not published by source' instead of estimating. The Restaurant Catalog must expose every Chain row and Data Sources must show sourceUrl and sourceDateEpochDay.

**Acceptance criteria:**
- When nutrition_seed_v1.json contains 30 Chain records, Restaurant Catalog displays 30 chains after Launch completes.
- A MenuItem with verified=false displays an amber unverified badge on Recommendations and Menu Item Detail.
- A nutrient stored as -1 or -1.0 displays 'Not published by source' and is excluded from numeric macro scoring.
- Data Sources displays the exact sourceUrl and sourceDateEpochDay for the selected Chain.

### Diet, allergy, and macro recommendation engine

Ranks menu items against calorie and protein goals while strictly excluding items that violate vegetarian or gluten preferences.

- **Answers complaint:** Data accuracy and coverage

- **Screens:** Recommendations, Preference Onboarding, Menu Item Detail

- **Estimated hours:** 60

**Implementation notes:** Implement a pure Kotlin RecommendationEngine with input UserPreference plus a list of MenuItem rows. First filter out unverified rows only if a verified alternative exists for the same chain/category; otherwise keep but mark as unverified. If dietType='vegetarian', exclude MenuItem.isVegetarian=false. If avoidGluten=true, exclude MenuItem.containsGluten=true. For remaining rows, compute score as: start 100. If calorieTargetPerMealKcal is set and caloriesKcal>=0, subtract min(60, abs(caloriesKcal-target)/target*60). If proteinMinG is set and proteinG>=0, add 20 when proteinG>=proteinMinG, otherwise subtract min(30, (proteinMinG-proteinG)/proteinMinG*30). Subtract 15 for verified=false. Sort descending by score, then chain name, then item name. Return top 50.

**Acceptance criteria:**
- With vegetarian preference enabled, no rendered recommendation has isVegetarian=false.
- With avoidGluten enabled, no rendered recommendation has containsGluten=true.
- Given two otherwise equal items, the item closer to calorieTargetPerMealKcal sorts first.
- Given two otherwise equal items, the item meeting proteinMinG sorts before the item below proteinMinG.
- The RecommendationEngine unit test can run without Android framework classes.

### Metric unit and non-US coverage handling

Provides a metric unit switch and displays launch-chain country coverage honestly instead of presenting a US-only dataset as worldwide.

- **Answers complaint:** No international support

- **Screens:** Preference Onboarding, Settings, Recommendations, Restaurant Catalog, Menu Item Detail

- **Estimated hours:** 44

**Implementation notes:** Store unitSystem in UserPreference as 'us' or 'metric'. In Menu Item Detail and Recommendations, display servingSizeUs when unitSystem='us' and servingSizeMetric when unitSystem='metric'. For seed rows with servingSizeMetric empty but servingSizeUs containing an ounce value matching the pattern '(number) oz', convert ounces to grams using grams=ounces*28.3495, rounded to the nearest gram, and persist the generated servingSizeMetric during import. Restaurant Catalog must group Chain rows by countryCode and never show 'worldwide' unless the dataset contains multiple country codes for the same chain.

**Acceptance criteria:**
- Changing Settings unit system to metric updates Recommendation and Menu Item Detail serving-size labels without restarting the app.
- A seed item with servingSizeUs='3 oz' and empty servingSizeMetric imports as servingSizeMetric='85 g'.
- Restaurant Catalog shows countryCode grouping for every chain.
- No screen uses the word 'worldwide' unless the displayed chain has source-backed rows in more than one countryCode.

### Reliable subscription, account, and restore flow

Uses a post-preview subscription flow with RevenueCat restore and Firebase account linking so paying users are not locked out or charged twice.

- **Answers complaint:** Technical reliability

- **Screens:** Paywall, Account, Recommendations, Grounded Menu Answer

- **Estimated hours:** 40

**Implementation notes:** Configure one RevenueCat entitlement named 'pro' and one monthly product priced at $9.99/month in store configuration. Initialize Purchases once in Application.onCreate. Before account creation, use RevenueCat anonymous app user id. After successful Firebase email/password account creation or sign-in, call Purchases.logIn(firebaseUid) and copy CustomerInfo.entitlements['pro'].isActive into Firestore AppUser.subscriptionActive. Restore button calls Purchases.restorePurchases; if CustomerInfo shows pro active, update Firestore and navigate back to Recommendations. If restore returns no active entitlement, show restore_no_purchase without launching purchase. Disable Subscribe and Restore buttons while either operation is running to prevent double taps. Do not implement Google sign-in in v1.

**Acceptance criteria:**
- Subscribe and Restore buttons are disabled during an in-flight purchase or restore call.
- Calling restore twice in a row with an active entitlement leaves exactly one active UI entitlement state and does not open the purchase sheet.
- A user can continue limited preview without creating an account.
- No Google sign-in button or GoogleSignInClient dependency exists in the v1 UI or code.
- After RevenueCat reports pro active, locked premium UI is replaced by unlocked UI on the next rendered state.

### Grounded menu answer assistant

Answers user menu questions from the verified local dataset and refuses to invent items not present in source-backed rows.

- **Answers complaint:** Technical reliability

- **Screens:** Grounded Menu Answer, Menu Item Detail, Paywall

- **Estimated hours:** 16

**Implementation notes:** When the user submits a question, retrieve at most 20 candidate MenuItem rows from Room by restaurant scope and simple token match against name/category/allergens. Build an AI gateway request containing only the user question plus serialized candidate rows with id, name, chain, verified, calories, macros, allergens, and sourceUrl. The system instruction must require the model to answer only from provided candidates and to return JSON with fields answerText and citedItemIds. If no candidate rows exist, skip the network call and show no_verified_match. After response, discard any citedItemIds not present in the candidate set; if none remain, show no_verified_match. Wrap the OkHttp call in a 20-second timeout and render error state on timeout, non-2xx, JSON parse failure, or IOException without crashing.

**Acceptance criteria:**
- Submitting a question for a nonexistent item with zero Room candidates does not call the network and shows no_verified_match.
- If the AI gateway returns a citedItemId not in the candidate set, that citation is discarded before rendering.
- If all returned citations are discarded, the screen shows no_verified_match instead of an uncited answer.
- A simulated IOException from OkHttp renders the error state and the app process remains alive.
- The assistant screen requires an active pro entitlement after the free preview limit is reached.

## Store listing

- **Title:** PlatePilot Menus
- **Short description:** Verified eating-out picks before you pay.
- **Category:** Health & Fitness
- **Keywords:** restaurant nutrition, healthy eating out, macro guide, gluten filter, vegetarian menu, calorie restaurant, metric nutrition
- **Icon prompt:** Create a modern Android app icon for a healthy restaurant menu guide. Use a rounded square background in warm off-white #FAF7F0 with a centered simple fork-and-knife glyph in deep green #1B6E5C. Add a small verified check badge in the lower-right corner of the glyph. Flat vector style, high contrast, no text, no restaurant logos, no gradients, suitable for Play Store launcher icon.

**Long description:**

PlatePilot Menus helps you find healthier restaurant choices without the forced-paywall frustration. Set simple preferences like vegetarian eating, gluten avoidance, calorie target, protein goal, and US or metric units, then preview real personalized recommendations before creating an account or subscribing.

Nutrition values are sourced from published chain nutrition data where available, and anything not verified is clearly labeled instead of guessed. Browse covered restaurants, inspect source links, compare macros, and ask grounded menu questions that cite real menu items from the dataset.

PlatePilot Menus is not medical advice and cannot guarantee allergy safety. Always confirm ingredients and allergen handling directly with the restaurant.

## Legal

- **Regulated category:** health
- **Privacy policy URL:** https://platepilot.example.com/privacy (privacy claims verified: no)
- **Data collected:** Email address after optional account creation; Dietary preferences such as vegetarian preference, gluten avoidance, calorie target, protein target, and unit system; Subscription status and purchase entitlement metadata; Menu questions sent to the AI gateway; Firebase user identifier; RevenueCat app user identifier

## Test plan

### 1. Payment before value (instrumented)

1. Install a fresh debug build and clear app data.
2. Launch the app.
3. Wait for Launch to finish importing the bundled seed dataset.
4. On Preference Onboarding, select diet type Vegetarian, enable gluten avoidance, enter calorie target 600, enter protein minimum 20, and select Metric.
5. Tap Continue to recommendations.
6. Inspect the current navigation route and visible UI nodes.

**Expected:** The app is on Recommendations with at least one recommendation card or the recommendation empty state, and there is no visible email field, password field, card field, Subscribe button, or full-screen Paywall.

### 2. Recommendations reportedly ignore stated allergies and diet type (unit)

1. Create four MenuItem fixtures in a JVM unit test: vegetarian gluten-free, vegetarian containsGluten, nonVegetarian gluten-free, and nonVegetarian containsGluten.
2. Create UserPreference with dietType='vegetarian' and avoidGluten=true.
3. Call RecommendationEngine.rank with the fixtures.
4. Collect the ids returned by the engine.

**Expected:** Only the vegetarian gluten-free item id is returned; all containsGluten=true and isVegetarian=false fixtures are absent.

### 3. Calorie and macro figures reviewers cross-checked do not match restaurant source (unit)

1. Create a temporary nutrition_seed_v1.json fixture with one MenuItem whose caloriesKcal is 500, proteinG is 25.0, verified is true, and sourceUrl is 'https://source.example/menu.pdf'.
2. Run the seed import parser against an in-memory Room database.
3. Query the inserted MenuItem by id.
4. Render the Menu Item Detail formatter for calories, protein, verified badge, and source URL.

**Expected:** The queried and formatted values are exactly 500 kcal, 25 g protein, verified=true, and source URL 'https://source.example/menu.pdf'; no computed replacement value is used.

### 4. Some major chains are missing despite broad coverage claims (instrumented)

1. Install the app with a seed file containing exactly 30 Chain rows, including at least one row with category='coffee'.
2. Launch the app and wait for import completion.
3. Open Restaurant Catalog.
4. Search for category text 'coffee' using the catalog search field.

**Expected:** Restaurant Catalog shows at least one coffee-category chain and never displays copy claiming every restaurant or worldwide coverage.

### 5. Imperial units only and no metric switch (instrumented)

1. Use a seed item with servingSizeUs='3 oz' and servingSizeMetric='85 g'.
2. Launch the app, complete onboarding with unit system US, and open that Menu Item Detail.
3. Verify the serving size label.
4. Open Settings, switch unit system to Metric, and return to the same Menu Item Detail.

**Expected:** The detail screen first shows '3 oz' and then shows '85 g' after switching to Metric, without app restart.

### 6. Google sign-in loops (instrumented)

1. Launch the app and navigate to Account.
2. Inspect all clickable sign-in controls by visible text.
3. Enter a test email and password in the email/password fields.
4. Tap Sign in while Firebase Auth is mocked to return an auth error.

**Expected:** There is no Google sign-in button; the mocked auth error is displayed once in Account.auth_error state and the app remains on Account without a navigation loop.

### 7. Subscription restore repeatedly locks paying users out or charges them twice (instrumented)

1. Mock RevenueCat CustomerInfo to return entitlement 'pro' active.
2. Open Paywall.
3. Tap Restore purchases.
4. Wait until Paywall leaves restoring state.
5. Tap Restore purchases again.
6. Observe navigation and entitlement UI.

**Expected:** Both restore attempts complete without opening the purchase sheet; the app shows entitled state and premium content remains unlocked.

### 8. AI chat crashes the app and makes up menu items (instrumented)

1. Open Grounded Menu Answer with a Room database containing no MenuItem whose name or category matches 'rainbow unicorn latte'.
2. Type 'Is the rainbow unicorn latte vegetarian?'
3. Tap Ask.
4. Monitor whether an OkHttp request is enqueued and observe the screen state.

**Expected:** No network request is made, the screen shows no_verified_match, and the app process does not crash.

### 9. Baseline parity: paid users can access subscription-only assistant after preview (manual)

1. Install a release build from internal testing.
2. Complete onboarding and view at least 5 recommendation cards.
3. Open Paywall and purchase the $9.99/month product with a Play test account.
4. Return to Grounded Menu Answer.
5. Ask a question about a visible verified menu item.

**Expected:** The purchase succeeds, the pro entitlement unlocks, the assistant returns an answer citing the verified menu item, and the cited chip opens Menu Item Detail.

## Build instructions

```sh
keytool -genkeypair -v -keystore release.keystore -storepass changeit -keypass changeit -alias release -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=PlatePilot Menus,O=Solo Builder,L=San Francisco,ST=CA,C=US"
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
./gradlew bundleRelease -Pandroid.injected.signing.store.file=$PWD/release.keystore -Pandroid.injected.signing.store.password=changeit -Pandroid.injected.signing.key.alias=release -Pandroid.injected.signing.key.password=changeit
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
- `regulated_category_go_no_go`
