# RecipeSure — build spec

A recipe importer that makes reliability and transparent subscription terms the main product promise: imports are validated before saving, price limits are shown up front, meal plans are editable, and nutrition updates when servings change.

## 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:** ReciMe: Recipes & Meal Planner
- **Package id:** `com.recime.app`
- **Google Play:** https://play.google.com/store/apps/details?id=com.recime.app
- **appy.fyi report:** https://appy.fyi/report/com.recime.app
- **Category:** Food & Drink

## Overview

- **Working name:** RecipeSure (trademark cleared: no)
- **Package id:** `fyi.appy.recipesure`
- **Min / target SDK:** 26 / 35
- **Backend:** firebase
- **Estimated build time:** 5.75 weeks
- **Pricing:** subscription, $39.99 via `revenuecat`
- **Runtime AI:** yes (ai_gateway_llm): Extract structured recipe title, servings, ingredients, instructions, and nutrition estimates from screenshots, captions, shared text, or URL text when structured parsing fails. ≈ $0.03/call; Recalculate nutrition after ingredient edits and normalize ambiguous ingredient names for grocery-list merging. ≈ $0.01/call
- **Permissions:** `INTERNET`

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

- No public recipe social network, comments, creator profiles, or Facebook-group-style community features in v1.
- No meal delivery, grocery delivery, barcode pantry, or retailer checkout integrations in v1.
- No medical, weight-loss, diabetes, allergy-safety, or therapeutic diet recommendations; nutrition values are informational estimates only.
- No desktop web app or iOS app in v1.
- No offline-first AI extraction; imports that require screenshot/caption understanding require internet access.
- No attempt to bypass Google Play subscription management rules; the app must provide a clear in-app cancellation entry point that opens the user’s Google Play subscription management flow.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Compose Activity integration and Android Photo Picker launchers | `androidx.activity:activity-compose:1.9.3` |
| Compose navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| ViewModel integration with Compose screens | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6` |
| Lifecycle-aware Flow collection in Compose | `androidx.lifecycle:lifecycle-runtime-compose:2.8.6` |
| Local recipe, ingredient, meal plan, grocery list, and import-attempt persistence | `androidx.room:room-runtime:2.6.1` |
| Coroutine extensions for Room DAOs | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor | `androidx.room:room-compiler:2.6.1` |
| Image preview loading for imported screenshots and saved recipe images | `io.coil-kt:coil-compose:2.7.0` |
| HTTP client for fetching recipe URLs and calling the AI gateway through Firebase callable endpoints | `com.squareup.okhttp3:okhttp:4.12.0` |
| HTML parsing and JSON-LD extraction from recipe web pages | `org.jsoup:jsoup:1.18.1` |
| Strict JSON encoding and decoding for AI extraction responses | `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3` |
| Coroutine support for repositories, imports, and billing state flows | `org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0` |
| Firebase dependency version alignment | `com.google.firebase:firebase-bom:33.5.1` |
| Email/password or anonymous account identity for subscriptions and cloud import diagnostics | `com.google.firebase:firebase-auth:23.1.0` |
| Cloud storage of user profile, entitlement mirror, and import-health diagnostics | `com.google.firebase:firebase-firestore:25.1.1` |
| Callable Firebase endpoint for AI extraction so LLM credentials are never stored in the APK | `com.google.firebase:firebase-functions:21.1.0` |
| Subscription purchase, entitlement checking, and in-app cancellation-management entry point | `com.revenuecat.purchases:purchases:8.10.5` |

## Design system

- **Primary color:** `#2F6F4E`
- **Background color:** `#FFF9F0`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `fork-knife` (regular weight)
- **Theme notes:** Use Material 3 light and dark themes. Light theme uses #FFF9F0 as the app background, #2F6F4E for primary buttons and selected navigation, and #4A2F1B for high-emphasis text. Dark theme uses #18130E background, #8BD6A8 primary, #F2E8DC surface, and #F2B8B5 error. Cards use 16dp rounded corners; primary actions are full-width on onboarding, import review, and subscription screens.

## Screens

### Launch
- **Route:** `launch`
- **Purpose:** Checks authentication, entitlement, and whether the user has already seen the honest free-tier and price disclosure.
- **Reached via:** app launch
- **Key UI elements:** Centered app logo using fork-knife glyph; Progress indicator; Error card with retry button if Firebase or RevenueCat initialization fails
- **States:** loading, error

### Honest Pricing
- **Route:** `pricing`
- **Purpose:** Shows the free import limit and annual paid tier before any questionnaire, review prompt, or subscription action.
- **Reached via:** Launch when disclosure has not been accepted; Settings subscription card; Import screen when the free weekly import limit is reached
- **Key UI elements:** Text: Free tier includes 5 imports per week; Text: Paid tier is $39.99 per year; Continue free button; Start paid tier button; Plain-language subscription terms; No review prompt on this screen
- **States:** populated, purchase_loading, purchase_error

### Import
- **Route:** `import`
- **Purpose:** Accepts a recipe URL, pasted caption text, shared text, or screenshot/image and starts validated recipe extraction.
- **Reached via:** bottom navigation Import tab; Android ACTION_SEND text/plain share intent; Android ACTION_SEND image/* share intent; Android ACTION_SEND_MULTIPLE image/* share intent
- **Key UI elements:** URL text field; Paste caption text field; Pick screenshot button using Android Photo Picker; Import button; Weekly free-import counter; Recent import attempts list with status; Share-sheet support note for Instagram/text/image sharing
- **States:** empty, ready, importing, error, limit_reached, populated

### Import Review
- **Route:** `importReview/{importAttemptId}`
- **Purpose:** Displays extracted recipe data before it is committed, with validation warnings for missing ingredients or instructions.
- **Reached via:** successful extraction from Import screen; tap a recent import attempt that completed but was not saved
- **Key UI elements:** Recipe title field; Servings field; Ingredient editable list; Instruction editable list; Nutrition estimate section; Validation warning banner; Save recipe button disabled until required fields are valid; Discard button
- **States:** loading, validation_error, populated, save_loading, save_error

### Recipe Library
- **Route:** `recipes`
- **Purpose:** Lists saved recipes and provides search so a user can open reliably saved recipes.
- **Reached via:** bottom navigation Recipes tab; after saving a recipe from Import Review
- **Key UI elements:** Search field; Recipe card list; Empty-state import call-to-action; Error card if local database read fails
- **States:** loading, empty, error, populated

### Recipe Detail
- **Route:** `recipe/{recipeId}`
- **Purpose:** Shows a saved recipe with editable ingredients, instructions, servings, and recalculated nutrition.
- **Reached via:** tap recipe card on Recipe Library; tap recipe from Meal Plan; tap recipe from Grocery List source row
- **Key UI elements:** Recipe title; Servings stepper; Ingredient list scaled to selected servings; Instruction list; Nutrition total and per-serving cards; Edit button; Add to meal plan button; Delete recipe button
- **States:** loading, not_found, error, populated, saving

### Meal Plan
- **Route:** `mealPlan`
- **Purpose:** Shows scheduled meals and lets existing meal-plan entries be edited in place instead of deleted and rebuilt.
- **Reached via:** bottom navigation Meal Plan tab; Add to meal plan button on Recipe Detail
- **Key UI elements:** Week selector; Day columns; Meal slot cards; Edit meal entry bottom sheet; Add recipe button; Remove entry button; Save changes button
- **States:** loading, empty, error, populated, saving

### Grocery List
- **Route:** `grocery`
- **Purpose:** Builds a consolidated grocery list from selected meal-plan entries and normalizes repeated quantities.
- **Reached via:** bottom navigation Grocery tab; Meal Plan generate groceries action
- **Key UI elements:** Generate from meal plan button; Grouped grocery sections; Quantity and unit rows; Checked-off item toggle; Regenerate button; Normalization note for combined quantities
- **States:** loading, empty, error, populated, normalizing

### Subscription Settings
- **Route:** `settings/subscription`
- **Purpose:** Shows current plan, renewal state, and a clear cancellation-management action inside the app.
- **Reached via:** Settings tab subscription row; Honest Pricing after purchase completion; Import limit reached screen
- **Key UI elements:** Current plan card; Free-tier import usage; Renewal/cancellation state text from RevenueCat customer info; Manage or cancel subscription button; Restore purchases button; Billing support explanation
- **States:** loading, not_subscribed, subscribed, cancel_pending, error

## Data model

### UserProfile (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| uid | `String` | primary key, Firebase Auth uid |
| email | `String?` | nullable; only present for email-auth users |
| createdAt | `Instant` | server timestamp |
| priceDisclosureAcceptedAt | `Instant?` | nullable until Honest Pricing is accepted |
| weeklyFreeImportCount | `Int` | reset by app logic when weekStart changes |
| weekStart | `LocalDate` | Monday in device locale at the time the counter period began |

### EntitlementMirror (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| uid | `String` | primary key, Firebase Auth uid |
| isPaid | `Boolean` | mirrors RevenueCat active entitlement |
| productId | `String?` | nullable; expected annual product id when subscribed |
| expiresAt | `Instant?` | nullable for free users |
| willRenew | `Boolean` | false when RevenueCat reports cancellation or non-renewal |
| updatedAt | `Instant` | server timestamp |

### Recipe (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, UUID |
| title | `String` | non-empty |
| sourceUrl | `String?` | nullable for screenshot or pasted-caption imports |
| sourceType | `String` | one of url, caption, screenshot, share_text, share_image |
| baseServings | `Double` | must be greater than 0 |
| selectedServings | `Double` | defaults to baseServings |
| createdAt | `Instant` |  |
| updatedAt | `Instant` |  |
| nutritionStale | `Boolean` | true after ingredient edits until recalculation completes |

### Ingredient (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, UUID |
| recipeId | `String` | foreign key to Recipe.id, cascade delete |
| position | `Int` | 0-based display order |
| quantity | `Double?` | nullable for ingredients such as salt to taste |
| unit | `String?` | nullable; examples: cup, tsp, g, onion |
| name | `String` | ingredient display name |
| canonicalName | `String` | lowercase normalized name used for grocery merging |
| note | `String?` | nullable preparation note |

### Instruction (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, UUID |
| recipeId | `String` | foreign key to Recipe.id, cascade delete |
| position | `Int` | 0-based display order |
| text | `String` | non-empty |

### NutritionSnapshot (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| recipeId | `String` | primary key and foreign key to Recipe.id, cascade delete |
| baseTotalCalories | `Double` | estimated total calories for baseServings |
| baseTotalProteinGrams | `Double` | estimated total protein for baseServings |
| baseTotalCarbsGrams | `Double` | estimated total carbs for baseServings |
| baseTotalFatGrams | `Double` | estimated total fat for baseServings |
| estimatedAt | `Instant` |  |

### MealPlanEntry (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, UUID |
| recipeId | `String` | foreign key to Recipe.id |
| date | `LocalDate` |  |
| mealSlot | `String` | breakfast, lunch, dinner, snack, or custom |
| plannedServings | `Double` | editable in place |
| note | `String?` | nullable |
| updatedAt | `Instant` |  |

### GroceryListItem (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, UUID |
| canonicalName | `String` | merge key from Ingredient.canonicalName |
| displayName | `String` |  |
| quantity | `Double?` | summed quantity, nullable when unquantified |
| unit | `String?` | normalized unit when compatible |
| sourceMealPlanEntryIds | `List<String>` | stored through Room type converter as JSON array |
| checked | `Boolean` |  |

### ImportAttempt (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, UUID |
| sourceType | `String` | url, caption, screenshot, share_text, or share_image |
| sourcePackage | `String?` | nullable; package name from share intent when available |
| sourcePreview | `String` | first 200 chars of URL/caption or image filename |
| status | `String` | draft, extracting, needs_review, saved, failed |
| errorMessage | `String?` | nullable; user-visible failure reason |
| createdAt | `Instant` |  |
| completedAt | `Instant?` | nullable |

### PlatformImportHealth (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| platformKey | `String` | primary key such as instagram:image_share or instagram:text_share |
| lastSuccessfulAt | `Instant?` | nullable |
| lastFailedAt | `Instant?` | nullable |
| failureCount24h | `Int` | incremented by callable import logging endpoint |
| lastErrorSample | `String?` | nullable, truncated to 300 chars |

## Features

### Validated AI-first recipe import

Imports recipes from URLs, pasted captions, shared text, or screenshots and refuses to save incomplete recipes as if they succeeded.

- **Answers complaint:** Import reliability

- **Screens:** Import, Import Review, Recipe Library, Recipe Detail

- **Estimated hours:** 78

**Implementation notes:** Register MainActivity for ACTION_SEND text/plain, ACTION_SEND image/*, and ACTION_SEND_MULTIPLE image/* in AndroidManifest with exported=true. On Import, create ImportAttempt(status='extracting') before work starts. For sourceType=url, fetch the page with OkHttp using a 15-second timeout, parse JSON-LD Recipe objects with Jsoup, and accept the parse only if title is non-empty, ingredient count >= 1, and instruction count >= 1. For captions, screenshots, shared text, or failed URL parsing, call a Firebase Callable Function named extractRecipeFromSource; pass sourceType, text/url, and for images a base64 JPEG under 4 MB. The function calls ai_gateway_llm with temperature 0 and requires JSON keys title, servings, ingredients[{quantity,unit,name,note}], instructions[], nutrition{calories,proteinGrams,carbsGrams,fatGrams}. In the Android client, decode with kotlinx.serialization using ignoreUnknownKeys=false and validate required fields. If validation fails, set ImportAttempt(status='failed', errorMessage='Could not find ingredients and instructions. Paste more text or choose another screenshot.') and do not create Recipe rows. If validation passes, store the extracted draft in memory and navigate to Import Review with ImportAttempt(status='needs_review'). Only after the user taps Save on Import Review should Room run a single transaction inserting Recipe, Ingredient, Instruction, and NutritionSnapshot rows and then update ImportAttempt(status='saved').

**Acceptance criteria:**
- A URL containing valid Recipe JSON-LD with at least one ingredient and one instruction reaches Import Review without using the AI fallback.
- A pasted caption with no structured HTML reaches Import Review through the AI callable path when the AI returns valid JSON.
- An extraction response with an empty ingredients array is shown as a validation error and no Recipe row is inserted.
- After saving from Import Review, opening the new recipe from Recipe Library shows title, ingredients, and instructions without a generic error.
- If network is unavailable during an AI-required import, ImportAttempt is marked failed and the Import screen shows a retryable error instead of a saved recipe.

### Instagram and share-sheet import support with health monitoring

Makes the app visible as an Android share target for Instagram-style text and image shares and records platform-specific import failures.

- **Answers complaint:** Instagram importing is completely broken. ReciMe disappeared from Instagram's full share app list.

- **Screens:** Import

- **Estimated hours:** 18

**Implementation notes:** Add intent filters for android.intent.action.SEND with mimeType text/plain and image/* plus android.intent.action.SEND_MULTIPLE with mimeType image/*. In MainActivity.onCreate, inspect intent.action and intent.type; for text/plain read Intent.EXTRA_TEXT into the Import screen caption field, and for image streams use ContentResolver.openInputStream on Intent.EXTRA_STREAM, downscale to max 1600 px longest side, JPEG-compress at quality 85, and create an ImportAttempt with sourcePackage=intent.`package` if available. After every import attempt finishes, call Firebase Callable Function logImportHealth with sourcePackage, mimeType, status, and error code. The function updates PlatformImportHealth by platformKey, increments failureCount24h for failed attempts, and sets lastSuccessfulAt for successful attempts. The Import screen recent-attempt list must display source package and status so regressions are visible during QA.

**Acceptance criteria:**
- Android Sharesheet shows RecipeSure for text/plain shares from another app.
- Android Sharesheet shows RecipeSure for image/* shares from another app.
- Receiving an image share opens the Import screen with an ImportAttempt row whose sourceType is share_image.
- A failed shared-image import calls logImportHealth and increments failureCount24h for that platform key.
- A later successful shared-image import updates lastSuccessfulAt for the same platform key.

### Honest price and free-tier disclosure before setup

Shows the 5-imports-per-week free limit and the $39.99/year paid tier before any questionnaire, review prompt, or purchase flow.

- **Answers complaint:** Hidden paywall

- **Screens:** Launch, Honest Pricing, Import

- **Estimated hours:** 16

**Implementation notes:** On first launch, Launch checks UserProfile.priceDisclosureAcceptedAt. If null, navigate to Honest Pricing before any other onboarding screen. Honest Pricing must show exact text 'Free: 5 recipe imports per week' and 'Paid: $39.99/year' above any Continue button. The app must not invoke Play Core in-app review in v1. When Continue free is tapped, write priceDisclosureAcceptedAt to Firestore and continue to Import. Track weeklyFreeImportCount in UserProfile; when a free user starts an import, compare weekStart to current Monday, reset count if the week changed, and block the 6th import by navigating to Honest Pricing with limit_reached state. Paid users bypass the weekly limit based on RevenueCat active entitlement.

**Acceptance criteria:**
- A fresh install navigates from Launch to Honest Pricing before any questionnaire-like screen or purchase sheet appears.
- The Honest Pricing screen visibly contains both '5 recipe imports per week' and '$39.99/year'.
- No review prompt is triggered before price disclosure acceptance.
- A free user can complete five imports in the same week.
- A free user attempting a sixth import in the same week is blocked before extraction starts and sees the limit explanation.

### Subscription purchase, entitlement display, and in-app cancellation entry

Provides a clear opt-in subscription purchase and a visible in-app place to manage or cancel the subscription.

- **Answers complaint:** Billing trust

- **Screens:** Honest Pricing, Subscription Settings, Import

- **Estimated hours:** 36

**Implementation notes:** Configure RevenueCat with one annual entitlement named pro_yearly mapped to a Google Play subscription product priced at $39.99/year. The Start paid tier button on Honest Pricing must call Purchases.sharedInstance.getOfferings, display the annual package price returned by RevenueCat, and only then call purchasePackage after the user taps a button labeled 'Subscribe for $39.99/year'. After purchase, read CustomerInfo.entitlements['pro_yearly'].isActive and mirror isPaid, expiresAt, willRenew, and productId to EntitlementMirror in Firestore. Subscription Settings must call getCustomerInfo on entry and show not_subscribed, subscribed, or cancel_pending based on active entitlement and willRenew. The Manage or cancel subscription button must open the platform subscription-management Intent returned by RevenueCat/Google Play for the active product; if no product is active, it is hidden and Restore purchases remains visible. Never show language implying cancellation by email.

**Acceptance criteria:**
- The Google Play purchase sheet is not launched until after the user taps a button whose label includes the annual price.
- After a successful purchase, Subscription Settings shows the user as subscribed without restarting the app.
- Restore purchases refreshes CustomerInfo and updates EntitlementMirror.
- A subscribed user sees a Manage or cancel subscription button in Subscription Settings.
- The app never displays an instruction to email support to cancel.

### Recipe editing without endless save spinner

Lets users edit saved recipe fields and guarantees that saves either complete atomically or show a specific error.

- **Answers complaint:** edits that spin forever without saving

- **Screens:** Recipe Detail

- **Estimated hours:** 14

**Implementation notes:** Recipe Detail edit mode uses a ViewModel state machine: Idle, Dirty, Saving, Saved, Error(message). On Save, disable inputs, start a coroutine with withTimeout(10000), and run a Room transaction updating Recipe.updatedAt, Ingredient rows, Instruction rows, and nutritionStale=true if ingredients changed. On success, emit Saved for 1200 ms then Idle. On timeout or exception, emit Error with the exception message mapped to user text and re-enable inputs. Do not perform remote writes as part of the recipe edit save path, so local edits are not blocked by Firebase availability.

**Acceptance criteria:**
- Editing a recipe title and tapping Save updates the Room Recipe row and returns the screen to non-saving state.
- If the DAO throws during save, the Save button becomes enabled again and an error banner is visible.
- The Saving state cannot remain visible longer than 10 seconds in a failing save test.
- Ingredient edits set nutritionStale=true so recalculation can be requested.

### Editable meal plan entries

Allows an existing meal-plan item’s recipe, date, slot, servings, and note to be changed in place.

- **Answers complaint:** A saved meal plan can only be deleted and rebuilt, not edited in place

- **Screens:** Meal Plan, Recipe Detail, Grocery List

- **Estimated hours:** 26

**Implementation notes:** Meal Plan displays MealPlanEntry rows grouped by date for the selected week. Tapping an existing entry opens an edit bottom sheet populated from that row. The user can change date, mealSlot, plannedServings, note, or recipeId. Save runs a Room UPDATE on the existing id rather than DELETE plus INSERT, updates updatedAt, dismisses the sheet, and refreshes the week query. GroceryListItem.sourceMealPlanEntryIds remains valid because the MealPlanEntry id is unchanged.

**Acceptance criteria:**
- Tapping a meal-plan entry opens a populated edit sheet.
- Changing dinner to lunch and saving updates the same MealPlanEntry id.
- Changing planned servings from 2 to 4 persists and is visible after leaving and returning to Meal Plan.
- Editing an entry does not remove its id from generated grocery sourceMealPlanEntryIds.

### Serving-based nutrition recalculation

Updates displayed nutrition totals immediately when the selected serving count changes.

- **Answers complaint:** nutrition figures don't recalculate when serving size changes

- **Screens:** Recipe Detail, Import Review

- **Estimated hours:** 18

**Implementation notes:** Store NutritionSnapshot base totals for Recipe.baseServings. In Recipe Detail, selectedServings is editable by a stepper constrained to 0.5 through 99. Compute scale = selectedServings / baseServings. Display total calories/protein/carbs/fat as base totals multiplied by scale, rounded to nearest whole calorie and one decimal gram. Display per-serving values as displayed total divided by selectedServings. Persist selectedServings on change. If nutritionStale=true after ingredient edits, show a 'Nutrition needs recalculation' banner and a Recalculate button that calls extractRecipeFromSource with current recipe text and updates NutritionSnapshot on valid response.

**Acceptance criteria:**
- For a recipe with baseServings=2 and baseTotalCalories=600, changing selectedServings to 4 shows total calories as 1200.
- For the same recipe, per-serving calories remain 300 when selectedServings changes from 2 to 4.
- Selected servings persist after navigating away and reopening Recipe Detail.
- After ingredient edits, the nutrition stale banner appears until recalculation succeeds.

### Normalized grocery list generation

Generates a grocery list from the meal plan and combines repeated ingredients such as four half-onion entries into two onions.

- **Answers complaint:** The same approach can normalize units across a shopping list ("1/2 onion" four times becoming "2 onions")

- **Screens:** Grocery List, Meal Plan

- **Estimated hours:** 24

**Implementation notes:** When Generate from meal plan is tapped, load MealPlanEntry rows for the selected week and their Ingredient rows. For each ingredient, multiply quantity by plannedServings / recipe.baseServings. Normalize canonicalName by lowercasing, trimming, removing parenthetical notes, and using the AI-provided canonicalName when present. Convert compatible units with a fixed table: tsp=5 ml, tbsp=15 ml, cup=240 ml, ml=1 ml, l=1000 ml, g=1 g, kg=1000 g, oz=28.3495 g, lb=453.592 g. Treat unitless count nouns and units such as onion, clove, egg, can, bunch as count-compatible and sum directly. Group by canonicalName plus compatible unit category. Insert or replace GroceryListItem rows in one Room transaction. For the explicit onion case, four ingredients with quantity=0.5, unit='onion', canonicalName='onion' produce one item quantity=2, unit='onion'.

**Acceptance criteria:**
- Four planned recipes each containing 0.5 onion generate one grocery item with quantity 2 and unit onion.
- One recipe with 1 tbsp olive oil and another with 3 tsp olive oil generate one compatible oil row measured as 30 ml or 2 tbsp consistently.
- Incompatible units for the same canonical name remain separate rows instead of being incorrectly merged.
- Checked state can be toggled and remains visible after navigating away and back.

## Store listing

- **Title:** RecipeSure
- **Short description:** Reliable recipe imports, honest pricing, editable meal plans.
- **Category:** Food & Drink
- **Keywords:** recipe importer, meal planner, grocery list, recipe saver, nutrition, Instagram recipes, AI recipe extraction
- **Icon prompt:** Create a modern Android app icon for a recipe importer called RecipeSure: warm cream background, deep green rounded square foreground, simple white fork-and-knife glyph centered, subtle shadow, no text, no brand logos, clean Material Design style, high contrast, suitable for Play Store launcher icon.

**Long description:**

RecipeSure helps you save recipes from links, captions, shared text, and screenshots without pretending broken imports worked. Every import is validated before it becomes a saved recipe, so missing ingredients or instructions are caught while you can fix them.

The free tier is clear from the start: 5 imports per week. The paid tier is shown before purchase: $39.99 per year.

Plan meals without rebuilding them from scratch, generate a normalized grocery list, and adjust servings with nutrition totals that update immediately. Subscription status and cancellation management are visible inside the app.

## Legal

- **Regulated category:** health
- **Privacy policy URL:** https://recipesure.app/privacy (privacy claims verified: no)
- **Data collected:** Firebase user ID; Email address if the user chooses email authentication; Subscription entitlement status and product identifier; Recipe URLs, pasted captions, shared text, and selected recipe screenshots sent for extraction; Saved recipe, ingredient, instruction, meal-plan, grocery-list, and nutrition-estimate data stored locally on device; Import attempt status, source type, source package when available, and truncated error diagnostics for platform import-health monitoring

## Test plan

### 1. recipes that show as saved but throw "something went wrong" when opened (instrumented)

1. Launch the app with a seeded fake AI response containing title 'Test Pasta', one ingredient, one instruction, and nutrition values.
2. On Import, paste caption text 'Test pasta recipe' and tap Import.
3. On Import Review, tap Save recipe.
4. Navigate to Recipe Library.
5. Tap the 'Test Pasta' recipe card.

**Expected:** Recipe Detail opens in populated state and displays the saved title, ingredient, and instruction; no generic error screen appears.

### 2. imports missing ingredients or instructions entirely (unit)

1. Call the import validation function with extracted JSON containing title='Soup', servings=2, ingredients=[], and instructions=['Simmer'].
2. Assert validation result is invalid.
3. Run the repository save path with that validation result.

**Expected:** No Recipe, Ingredient, Instruction, or NutritionSnapshot rows are inserted, and the returned error says ingredients and instructions could not be found.

### 3. edits that spin forever without saving (unit)

1. Create a fake RecipeDao whose update transaction suspends for 11 seconds.
2. Open Recipe Detail ViewModel edit mode and change the title.
3. Call saveRecipe().
4. Advance the test coroutine scheduler by 10001 milliseconds.

**Expected:** ViewModel state is Error, not Saving, and the Save button enabled flag is true.

### 4. Hidden paywall (manual)

1. Clear app data and install a fresh build.
2. Open the app.
3. Observe the first non-loading screen.
4. Do not tap any purchase button.

**Expected:** The first non-loading screen is Honest Pricing and it visibly states 'Free: 5 recipe imports per week' and '$39.99/year' before any questionnaire, review prompt, or purchase sheet appears.

### 5. free tier's 5-imports-a-week cap is reached fast (instrumented)

1. Seed a free UserProfile with weeklyFreeImportCount=5 and weekStart equal to the current Monday.
2. Open Import.
3. Enter any caption text.
4. Tap Import.

**Expected:** No ImportAttempt with status extracting is created; navigation goes to Honest Pricing in limit_reached state.

### 6. charged without a clear opt-in moment (manual)

1. Open Honest Pricing as a free user.
2. Tap Start paid tier.
3. Observe the app screen immediately before the Google Play purchase sheet opens.

**Expected:** A user-visible button labeled with the annual price, such as 'Subscribe for $39.99/year', is tapped before the Google Play purchase sheet opens.

### 7. no visible in-app way to cancel (manual)

1. Sign in as a user with an active test subscription.
2. Open Subscription Settings.
3. Look for cancellation or management controls.
4. Tap the Manage or cancel subscription button.

**Expected:** Subscription Settings shows active subscription status and a Manage or cancel subscription button that opens the Google Play subscription management flow for the active product.

### 8. A saved meal plan can only be deleted and rebuilt, not edited in place (instrumented)

1. Seed a Recipe and a MealPlanEntry with mealSlot='dinner', plannedServings=2, and id='entry-1'.
2. Open Meal Plan.
3. Tap the dinner entry.
4. Change meal slot to lunch and planned servings to 4.
5. Tap Save changes.
6. Query the Room database for MealPlanEntry id='entry-1'.

**Expected:** The same row id 'entry-1' exists with mealSlot='lunch' and plannedServings=4; no replacement id is created.

### 9. nutrition figures don't recalculate when serving size changes (unit)

1. Create a Recipe with baseServings=2 and selectedServings=2.
2. Create NutritionSnapshot with baseTotalCalories=600, baseTotalProteinGrams=30, baseTotalCarbsGrams=80, baseTotalFatGrams=20.
3. Call the nutrition display calculation with selectedServings=4.

**Expected:** Displayed total calories are 1200, total protein is 60.0g, and per-serving calories are 300.

### 10. Instagram importing is completely broken / disappeared from share app list (manual)

1. Install the debug build on a device with any app capable of sharing an image through Android Sharesheet.
2. Open that app and share a recipe screenshot image.
3. Inspect the Android Sharesheet target list.
4. Choose RecipeSure.
5. Return to RecipeSure Import screen.

**Expected:** RecipeSure appears as an image share target, opens to Import, and shows a recent ImportAttempt with sourceType share_image.

### 11. normalize units across a shopping list ("1/2 onion" four times becoming "2 onions") (unit)

1. Create four Ingredient objects with quantity=0.5, unit='onion', name='onion', canonicalName='onion'.
2. Create four MealPlanEntry objects whose serving scale is 1.0.
3. Run the grocery normalization function.
4. Read the generated GroceryListItem list.

**Expected:** Exactly one GroceryListItem has canonicalName='onion', quantity=2.0, and unit='onion'.

## Build instructions

```sh
keytool -genkeypair -v -keystore ./release.keystore -storepass RecipeSure2026! -keypass RecipeSure2026! -alias recipesure_upload -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=RecipeSure Upload,O=RecipeSure,C=US"
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
./gradlew bundleRelease -Pandroid.injected.signing.store.file=$PWD/release.keystore -Pandroid.injected.signing.store.password=RecipeSure2026! -Pandroid.injected.signing.key.alias=recipesure_upload -Pandroid.injected.signing.key.password=RecipeSure2026!
```

## Human gates still required

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