# InkTrust Studio — build spec

A tattoo planning generator that shows one real watermarked result before payment, protects credits on failed generations, and uses tattoo-specific brief controls for cover-ups, placement, style, color, and fine-line ideas.

## 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:** Tattooist: AI Tattoo Generator
- **Package id:** `ai.tattoo.ink.generator`
- **Google Play:** https://play.google.com/store/apps/details?id=ai.tattoo.ink.generator
- **appy.fyi report:** https://appy.fyi/report/ai.tattoo.ink.generator
- **Category:** Art & Design

## Overview

- **Working name:** InkTrust Studio (trademark cleared: no)
- **Package id:** `fyi.appy.inktruststudio`
- **Min / target SDK:** 26 / 35
- **Backend:** firebase
- **Estimated build time:** 4.3 weeks
- **Pricing:** subscription, $6.99 via `revenuecat`
- **Runtime AI:** yes (image_gen_api): Critique a tattoo brief with optional existing tattoo/reference photo before generation ≈ $0.02/call; Generate one tattoo concept batch from the final tattoo-specific prompt and optional reference photo ≈ $0.08/call
- **Permissions:** `INTERNET`

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

- No full augmented-reality tattoo try-on in v1; placement is a static photo mockup only.
- No human tattoo artist marketplace, booking, chat, or shop directory.
- No unlimited free generation; v1 includes one free watermarked result before paid options.
- No medical, aftercare, infection, allergy, or skin-health advice.
- No social feed, public profile, likes, comments, or community gallery.
- No claim that generated art is legally unique, copyright-cleared, or ready to tattoo without artist review.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Compose Material 3 UI components | `androidx.compose.material3:material3:1.3.1` |
| Main Activity integration for Jetpack Compose | `androidx.activity:activity-compose:1.9.3` |
| Compose Navigation graph and typed route handling | `androidx.navigation:navigation-compose:2.8.4` |
| ViewModel integration with Compose screens | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7` |
| Lifecycle-aware collection of StateFlow in Compose | `androidx.lifecycle:lifecycle-runtime-compose:2.8.7` |
| Local Room database runtime for projects, generated designs, references, and queued jobs | `androidx.room:room-runtime:2.6.1` |
| Kotlin extensions and coroutine support for Room | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor via KSP for DAO/entity code generation | `androidx.room:room-compiler:2.6.1` |
| Reliable retry queue for pending generation jobs when network/API calls fail | `androidx.work:work-runtime-ktx:2.9.1` |
| Image loading for local result files, reference photos, and placement mockups in Compose | `io.coil-kt:coil-compose:2.7.0` |
| Firebase anonymous authentication for server-side credit, purchase, and job ownership | `com.google.firebase:firebase-auth:23.1.0` |
| Firestore client for user entitlement, credit ledger, and generation job metadata | `com.google.firebase:firebase-firestore:25.1.1` |
| Firebase callable functions client for brief critique, tattoo image generation, and failed-job refund calls | `com.google.firebase:firebase-functions:21.1.0` |
| Coroutine await support for Firebase Task APIs | `org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0` |
| RevenueCat Android SDK for the $6.99/month Pro subscription and $4.99 high-resolution design pack purchase flow | `com.revenuecat.purchases:purchases:8.10.4` |
| Kotlin serialization for generation request/response DTOs persisted locally and sent to callable functions | `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3` |

## Design system

- **Primary color:** `#5B2A86`
- **Background color:** `#FFF8F2`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `needle` (regular weight)
- **Theme notes:** Use a warm light theme by default with #FFF8F2 background and #5B2A86 primary actions; dark theme uses Material 3 dynamic dark surfaces only if system dark mode is enabled, while preserving #C9A7FF as the primary accent. Generated tattoo images are displayed on neutral #F2ECE7 cards so black-line and color designs remain legible.

## Screens

### OnboardingTrustScreen
- **Route:** `onboarding`
- **Purpose:** Explain the trustworthy flow: one free watermarked result before payment, credits are not spent on errors, and pricing is shown clearly.
- **Reached via:** app launch when firstRunComplete is false; SettingsScreen tap How InkTrust works
- **Key UI elements:** Three-page pager with value promises; Primary button labeled Start free tattoo brief; Secondary text link labeled View pricing first; Trust bullets: One free watermarked result, Failed generations are refunded, Cancel anytime for Pro
- **States:** populated

### BriefBuilderScreen
- **Route:** `brief`
- **Purpose:** Collect the tattoo idea, symbols, style, line weight, color preference, body placement, and whether the design is a cover-up.
- **Reached via:** OnboardingTrustScreen primary button; GalleryScreen floating action button; ResultReviewScreen tap Create another
- **Key UI elements:** Prompt text field with 500 character counter; Symbol chips such as animal, floral, memorial, geometric, lettering, abstract; Style selector with fine-line, traditional, neo-traditional, blackwork, watercolor, minimal; Color selector with black-and-gray, limited color, full color; Placement dropdown for arm, forearm, wrist, neck, chest, back, leg, ankle, other; Cover-up toggle; Reference photo card shown when cover-up is enabled; Generate button showing Free result available or Requires pack/Pro
- **States:** empty, editing, ready_to_generate, validation_error, submitting

### PhotoIntakeScreen
- **Route:** `photo-intake/{projectId}`
- **Purpose:** Let the user attach an existing tattoo or reference photo for cover-up suggestions and image-to-tattoo transformation.
- **Reached via:** BriefBuilderScreen tap Add existing tattoo photo; BriefBuilderScreen cover-up toggle when no photo is attached
- **Key UI elements:** Android Photo Picker launch button; Selected photo preview; Retake/replace button; Cover-up instruction text asking for a clear, well-lit existing tattoo photo; Continue to critique button
- **States:** empty, photo_selected, invalid_file_error, saving

### BriefCritiqueScreen
- **Route:** `critique/{projectId}`
- **Purpose:** Show the AI tattoo-constraint critique before generation so users can improve weak prompts and cover-up briefs.
- **Reached via:** BriefBuilderScreen tap Generate; PhotoIntakeScreen tap Continue to critique
- **Key UI elements:** Critique summary card; Warnings list for too many symbols, tiny lettering, poor cover-up contrast, unclear placement, or missing style; Suggested prompt rewrite preview; Buttons: Apply suggestions, Edit brief, Generate anyway
- **States:** loading, populated, error

### GenerationProgressScreen
- **Route:** `generation/{jobId}`
- **Purpose:** Track queued, retrying, generating, succeeded, and failed generation jobs without charging credits on errors.
- **Reached via:** BriefCritiqueScreen tap Generate anyway; BriefCritiqueScreen tap Apply suggestions then Generate; BriefBuilderScreen direct generate when critique is skipped after prior populated critique
- **Key UI elements:** Progress stepper: queued, checking brief, generating, saving result; Retry status text with attempt count; Message stating No credit is spent unless a usable result is saved; Cancel button while queued or retrying
- **States:** queued, running, retrying, succeeded_redirecting, failed_refunded, cancelled

### ResultReviewScreen
- **Route:** `result/{designId}`
- **Purpose:** Display the generated tattoo concepts, watermark status, prompt details, and actions to save, export, share, upgrade, or create a placement mockup.
- **Reached via:** GenerationProgressScreen on succeeded; GalleryScreen tap a generated design
- **Key UI elements:** Generated image carousel with 1 to 4 designs; Watermark badge for free result; Prompt and style summary expandable card; Buttons: Save to gallery, Share watermarked, Export high-resolution, Try placement mockup; Upgrade card for $4.99 high-resolution pack or $6.99/month Pro when high-resolution export is locked
- **States:** loading, populated_watermarked, populated_high_resolution, image_load_error

### PlacementMockupScreen
- **Route:** `placement/{designId}`
- **Purpose:** Create a static body placement mockup by overlaying the selected tattoo design on a user-selected body photo.
- **Reached via:** ResultReviewScreen tap Try placement mockup; GalleryScreen design overflow menu tap Placement mockup
- **Key UI elements:** Body photo picker button; Canvas-like preview with draggable, scalable, rotatable tattoo overlay; Opacity slider; Black ink preview toggle; Save mockup button
- **States:** empty, photo_selected, editing, saved, error

### GalleryScreen
- **Route:** `gallery`
- **Purpose:** Show saved tattoo projects, generated designs, and placement mockups.
- **Reached via:** bottom navigation Gallery tab; app launch when firstRunComplete is true
- **Key UI elements:** Project/design grid; Filter chips: All, Cover-ups, Placement mockups, High-resolution; Empty state button Create first brief; Design cards with thumbnail, style, placement, watermarked/high-res badge
- **States:** loading, empty, populated, error

### PricingScreen
- **Route:** `pricing`
- **Purpose:** Show the free-first-result promise, $4.99 high-resolution design pack, and optional $6.99/month Pro plan before purchase.
- **Reached via:** OnboardingTrustScreen tap View pricing first; BriefBuilderScreen generate when free result is already used and no entitlement exists; ResultReviewScreen tap Export high-resolution when locked; SettingsScreen tap Pricing and purchases
- **Key UI elements:** Free section: first watermarked result included; One-time pack card priced $4.99; Pro card priced $6.99/month; Plain-language renewal and cancellation text; Restore purchases button; Continue without paying button when free result remains
- **States:** loading_products, products_loaded, purchase_in_progress, purchase_success, purchase_error, restore_complete

### SettingsScreen
- **Route:** `settings`
- **Purpose:** Provide purchase restore, support copy, privacy link, and trust-policy explanation.
- **Reached via:** bottom navigation Settings tab
- **Key UI elements:** Restore purchases row; Pricing and purchases row; How InkTrust works row; Contact support mailto row; Privacy policy link; Delete local gallery data button
- **States:** populated, clearing_data, clear_data_success, clear_data_error

## Data model

### TattooProject (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key UUID |
| createdAtEpochMillis | `Long` | creation timestamp |
| updatedAtEpochMillis | `Long` | last local edit timestamp |
| rawPrompt | `String` | user-entered prompt |
| rewrittenPrompt | `String` | AI-suggested prompt rewrite; empty string if not applied |
| symbolsCsv | `String` | comma-separated selected symbol chips |
| style | `String` | one of fine_line, traditional, neo_traditional, blackwork, watercolor, minimal |
| colorMode | `String` | one of black_gray, limited_color, full_color |
| placement | `String` | selected body placement string |
| isCoverUp | `Boolean` | true when existing tattoo cover-up workflow is enabled |
| referencePhotoUri | `String` | nullable string stored as empty when no photo is attached |
| critiqueStatus | `String` | one of not_requested, loading, complete, failed |
| critiqueText | `String` | AI critique summary; empty if not available |

### GeneratedDesign (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key UUID |
| projectId | `String` | foreign key to TattooProject.id |
| jobId | `String` | foreign key to GenerationJob.remoteJobId when available |
| createdAtEpochMillis | `Long` | timestamp when usable image was saved locally |
| localImageUri | `String` | content or file URI for watermarked display image |
| highResLocalImageUri | `String` | empty until high-resolution export is unlocked and downloaded |
| thumbnailUri | `String` | local thumbnail URI generated after image save |
| isWatermarked | `Boolean` | true for the free first result and locked exports |
| isHighResolutionUnlocked | `Boolean` | true after $4.99 pack or Pro entitlement is active |
| variantIndex | `Int` | 0-based index when generation returns multiple similar concepts |

### PlacementMockup (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key UUID |
| designId | `String` | foreign key to GeneratedDesign.id |
| bodyPhotoUri | `String` | local URI of selected body photo |
| mockupImageUri | `String` | local URI of saved composited mockup image |
| scale | `Float` | overlay scale applied by user |
| rotationDegrees | `Float` | overlay rotation applied by user |
| offsetX | `Float` | overlay horizontal offset in preview coordinates |
| offsetY | `Float` | overlay vertical offset in preview coordinates |
| opacity | `Float` | 0.0 to 1.0 overlay opacity |
| createdAtEpochMillis | `Long` | timestamp |

### LocalGenerationQueueItem (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key UUID; matches WorkManager unique work name |
| projectId | `String` | foreign key to TattooProject.id |
| remoteJobId | `String` | Firestore generation job id; empty until created |
| status | `String` | one of queued, running, retrying, succeeded, failed_refunded, cancelled |
| attemptCount | `Int` | number of local attempts made |
| lastError | `String` | human-readable error message for failed/retrying state |
| createdAtEpochMillis | `Long` | timestamp |
| updatedAtEpochMillis | `Long` | timestamp |

### UserEntitlement (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| uid | `String` | document id; Firebase anonymous auth uid |
| freeGenerationUsed | `Boolean` | false until the first usable watermarked result is saved |
| highResolutionPackActive | `Boolean` | true after $4.99 pack purchase is verified |
| proActive | `Boolean` | true while $6.99/month Pro subscription is verified |
| updatedAtEpochMillis | `Long` | server-updated timestamp mirrored as epoch millis |

### CreditLedgerEntry (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | document id UUID |
| uid | `String` | Firebase anonymous auth uid |
| jobId | `String` | related generation job id |
| type | `String` | one of reserve_free, commit_free, reserve_paid, commit_paid, refund_failed |
| amount | `Int` | positive for grant/refund, negative for reserve/commit |
| reason | `String` | short reason such as generation_started, generation_saved, api_error_refund |
| createdAtEpochMillis | `Long` | server timestamp mirrored as epoch millis |

### GenerationJob (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | document id UUID |
| uid | `String` | Firebase anonymous auth uid |
| projectId | `String` | local TattooProject.id for correlation |
| status | `String` | one of queued, generating, succeeded, failed, refunded |
| prompt | `String` | final prompt sent to image generation API |
| hasReferencePhoto | `Boolean` | true if a cover-up/reference image was included |
| attemptCount | `Int` | server-side generation attempts |
| errorCode | `String` | empty unless failed |
| watermarkedImageUrls | `String` | JSON array string of generated watermarked image URLs |
| highResImageUrls | `String` | JSON array string of generated high-resolution image URLs; empty until entitlement allows access |
| createdAtEpochMillis | `Long` | server timestamp mirrored as epoch millis |
| updatedAtEpochMillis | `Long` | server timestamp mirrored as epoch millis |

## Features

### Free first watermarked result before paywall

Let every new user create and view one usable watermarked tattoo result before requiring a subscription, trial, ad, or card entry.

- **Answers complaint:** Trustless monetization

- **Screens:** OnboardingTrustScreen, BriefBuilderScreen, BriefCritiqueScreen, GenerationProgressScreen, ResultReviewScreen, PricingScreen

- **Estimated hours:** 18

**Implementation notes:** On first launch, anonymously sign in with FirebaseAuth.signInAnonymously and read UserEntitlement/{uid}. In BriefBuilderScreen, if freeGenerationUsed is false, label the generate button Free result available and route through BriefCritiqueScreen to GenerationProgressScreen without opening PricingScreen. The Firebase callable function createGenerationJob must reserve the free generation but only sets freeGenerationUsed=true after at least one watermarked image URL is returned and the Android client saves a GeneratedDesign row. ResultReviewScreen displays the saved watermarked image and upgrade card, but the user can view, save, and share the watermarked output before any purchase UI appears.

**Acceptance criteria:**
- A fresh anonymous user can navigate from onboarding to a generated watermarked result without seeing a purchase sheet or entering payment information.
- The PricingScreen is not opened automatically before the first successful result is displayed.
- After the first usable result is saved, UserEntitlement.freeGenerationUsed is true and subsequent generation attempts without an active pack or Pro entitlement route to PricingScreen.
- ResultReviewScreen clearly shows a watermark badge on the free result.

### Failure-safe generation retries and credit refunds

Failed generations, broken photo transforms, and API errors never consume the free generation or paid credit unless a usable image is saved.

- **Answers complaint:** Failed generations and wasted attempts

- **Screens:** GenerationProgressScreen, ResultReviewScreen

- **Estimated hours:** 28

**Implementation notes:** Create a LocalGenerationQueueItem and unique WorkManager job named generation-{queueItem.id}. The worker calls Firebase Functions createGenerationJob, then pollGenerationJob every 3 seconds up to 90 seconds. Retry transient network, HTTP 429, and HTTP 5xx failures with exponential backoff delays of 10 seconds, 30 seconds, and 90 seconds, capped at 3 attempts. If all attempts fail or the server returns failed, call refundFailedGeneration(jobId) and write status failed_refunded locally. A credit is committed only after the client downloads at least one image, writes it to app-specific storage, inserts GeneratedDesign, and calls commitGenerationSaved(jobId, designId). The UI text on GenerationProgressScreen must always show No credit is spent unless a usable result is saved while the job is queued/running/retrying.

**Acceptance criteria:**
- When the fake generation API returns an error on all attempts, no GeneratedDesign row is created and LocalGenerationQueueItem.status becomes failed_refunded.
- When a free user's generation fails, UserEntitlement.freeGenerationUsed remains false.
- When a paid/pro user's generation fails, a CreditLedgerEntry with type refund_failed is written for the job.
- When the fake API fails twice then succeeds, exactly one GeneratedDesign row is created and only one commit entry is written.

### Tattoo-specific brief builder with style, prompt mixing, fine-line, and color controls

Collect structured tattoo intent instead of relying on a single generic prompt box.

- **Answers complaint:** Generic or weak prompt following

- **Screens:** BriefBuilderScreen, BriefCritiqueScreen, ResultReviewScreen

- **Estimated hours:** 20

**Implementation notes:** BriefBuilderScreen stores a TattooProject draft in Room after every valid field change using debounced saves at 500 ms. The final prompt is assembled in this exact order: placement sentence, style sentence, color sentence, user prompt, selected symbols sentence, and cover-up sentence if enabled. Fine-line mode adds: use clean thin linework, avoid dense shading, keep small details readable as a tattoo. Color mode maps to black-and-gray, limited accent color, or full color. Validation requires rawPrompt length 8 to 500 characters, at least one style, one color mode, and one placement before Generate is enabled.

**Acceptance criteria:**
- Generate is disabled when the prompt is shorter than 8 characters.
- Selecting fine-line adds the fine-line instruction to the prompt sent to the generation function.
- Selecting full color adds a full-color tattoo design instruction to the prompt sent to the generation function.
- Selecting multiple symbol chips includes all selected symbols in the assembled prompt in a comma-separated sentence.

### Multimodal tattoo brief critique before generation

Use AI to critique cover-up, prompt-mixing, placement, and tattoo-readability constraints before spending a generation attempt.

- **Answers complaint:** Generic or weak prompt following

- **Screens:** BriefCritiqueScreen, BriefBuilderScreen, PhotoIntakeScreen

- **Estimated hours:** 22

**Implementation notes:** BriefCritiqueScreen calls Firebase Functions critiqueTattooBrief with the assembled prompt, style, colorMode, placement, isCoverUp, and a signed reference photo URL when present. The server uses a multimodal LLM/image-capable AI endpoint and returns JSON with summary, warnings, and rewrittenPrompt. The client parses the response with kotlinx.serialization and stores critiqueText and rewrittenPrompt in TattooProject. Apply suggestions replaces TattooProject.rewrittenPrompt and generation uses rewrittenPrompt; Generate anyway keeps rawPrompt-based assembly. If critiqueTattooBrief fails, show an error state with buttons Retry critique and Generate without critique; no credit is reserved until the generation step starts.

**Acceptance criteria:**
- A successful critique displays at least one summary card and stores critiqueStatus=complete in Room.
- Tapping Apply suggestions causes the next generation request to use TattooProject.rewrittenPrompt instead of only rawPrompt.
- If critique fails, the user can return to BriefBuilderScreen without losing entered fields.
- A critique failure does not create a GenerationJob and does not write any CreditLedgerEntry.

### Reference photo and cover-up intake

Allow users to upload an existing tattoo or reference photo so cover-up suggestions and image-to-tattoo transformations are part of the brief.

- **Answers complaint:** Missing tattoo workflows

- **Screens:** BriefBuilderScreen, PhotoIntakeScreen, BriefCritiqueScreen

- **Estimated hours:** 18

**Implementation notes:** Use AndroidX ActivityResultContracts.PickVisualMedia with ImageOnly so no READ_MEDIA_IMAGES permission is required. Copy the selected URI into app-specific storage under files/reference/{projectId}.jpg using ContentResolver.openInputStream, downscale the bitmap to a maximum long edge of 1600 px, and JPEG-compress at quality 88. Store the copied file URI in TattooProject.referencePhotoUri. When isCoverUp is true, the final prompt adds: design a cover-up concept that can visually distract from and incorporate the existing tattoo, with enough contrast and coverage for the selected placement. PhotoIntakeScreen must reject unreadable streams and files that decode to null with invalid_file_error.

**Acceptance criteria:**
- Selecting an image through the Photo Picker stores a copied local file URI, not the transient picker URI, in TattooProject.referencePhotoUri.
- Disabling the cover-up toggle keeps the reference photo saved but removes the cover-up sentence from the generated prompt.
- If ContentResolver.openInputStream returns null, PhotoIntakeScreen shows invalid_file_error and does not update TattooProject.referencePhotoUri.
- A cover-up project sent to critiqueTattooBrief has isCoverUp=true and hasReferencePhoto=true when a photo is attached.

### Static body placement mockups

Let users preview a generated tattoo design on a body photo with manual position, scale, rotation, and opacity controls.

- **Answers complaint:** Missing tattoo workflows

- **Screens:** PlacementMockupScreen, ResultReviewScreen, GalleryScreen

- **Estimated hours:** 24

**Implementation notes:** PlacementMockupScreen uses PickVisualMedia to choose a body photo, then renders the body photo and tattoo PNG/JPEG in a Compose Box. Implement transform gestures with Modifier.pointerInput and detectTransformGestures to update scale, rotationDegrees, offsetX, and offsetY. Opacity slider writes a Float 0.25 to 1.0. Save mockup captures the composited bitmap by drawing the body bitmap and transformed tattoo bitmap to an Android Canvas at the body image's displayed aspect ratio, writes JPEG quality 92 to files/mockups/{mockupId}.jpg, and inserts PlacementMockup in Room.

**Acceptance criteria:**
- A user can select a body photo and see the tattoo design overlaid on top of it.
- Pinch changes PlacementMockup.scale, rotation gesture changes rotationDegrees, and drag changes offsetX/offsetY before save.
- Changing opacity visibly changes the overlay alpha in the preview.
- Saving creates a PlacementMockup row and a readable local mockupImageUri displayed in GalleryScreen.

### Gallery, export, sharing, and watermarking

Persist generated concepts locally and support watermarked sharing plus high-resolution export for paid users.

- **Answers complaint:** baseline parity

- **Screens:** GalleryScreen, ResultReviewScreen, PlacementMockupScreen, PricingScreen

- **Estimated hours:** 20

**Implementation notes:** Generated images returned for the free result are watermarked server-side before URL return; the Android app also overlays a small bottom-right InkTrust Studio watermark when sharing a watermarked image by drawing text onto a copy with Canvas. GalleryScreen reads GeneratedDesign and PlacementMockup from Room sorted by createdAtEpochMillis descending. Share uses ACTION_SEND with a FileProvider content URI and MIME image/jpeg. High-resolution export is locked unless highResolutionPackActive or proActive is true; when unlocked, call downloadHighResolution(jobId, variantIndex), save the file to app-specific storage and MediaStore.Images using RELATIVE_PATH Pictures/InkTrust Studio, and set isHighResolutionUnlocked=true.

**Acceptance criteria:**
- A saved GeneratedDesign appears in GalleryScreen after app restart.
- Sharing a watermarked free result sends an image URI whose bitmap contains visible InkTrust Studio text in the bottom-right corner.
- Tapping Export high-resolution without an entitlement opens PricingScreen.
- With proActive=true, Export high-resolution saves an image into MediaStore Pictures/InkTrust Studio and marks the design unlocked.

### Clear pricing, purchase restore, and cancellation copy

Show the free-first promise, $4.99 high-resolution pack, and optional $6.99/month Pro plan with restore and plain-language cancellation text.

- **Answers complaint:** Trustless monetization

- **Screens:** PricingScreen, SettingsScreen, BriefBuilderScreen, ResultReviewScreen

- **Estimated hours:** 22

**Implementation notes:** Use RevenueCat Purchases SDK. Configure offerings with product identifiers high_res_pack_499 and pro_monthly_699. PricingScreen calls Purchases.sharedInstance.getOfferings and renders the package price strings returned by the store, while static explanatory copy states: first watermarked result is free, high-resolution design pack is $4.99, Pro is optional at $6.99/month and can be cancelled in Google Play subscriptions. Purchase buttons call purchaseWith; on success, sync entitlement state to Firestore UserEntitlement and refresh local UI. Restore purchases calls restorePurchases and shows restore_complete even when no active entitlement is found, with copy explaining no active purchase was found.

**Acceptance criteria:**
- PricingScreen displays both $4.99 high-resolution pack and $6.99/month Pro plan copy when offerings load.
- The Continue without paying button is visible only when freeGenerationUsed=false.
- A successful Pro purchase sets UserEntitlement.proActive=true and unlocks high-resolution export.
- Restore purchases can be triggered from SettingsScreen and returns to a non-loading state with a visible result message.

## Store listing

- **Title:** InkTrust Studio
- **Short description:** One free tattoo concept, safer credits, better cover-up briefs.
- **Category:** Art & Design
- **Keywords:** tattoo generator, tattoo ideas, cover up tattoo, fine line tattoo, tattoo placement, AI tattoo, tattoo design, body art
- **Icon prompt:** Create a 1024x1024 Android app icon for a tattoo planning app. Use a warm cream background, a deep violet circular field, and a simple white tattoo needle glyph drawing a small curved ink line. Flat vector style, centered composition, no text, no gradients, high contrast, rounded Android adaptive icon safe area.

**Long description:**

InkTrust Studio helps you plan tattoo concepts before you commit. Build a tattoo-specific brief with style, symbols, placement, fine-line and color controls, add an existing tattoo photo for cover-up planning, then generate one real watermarked result before paying. Failed generations do not spend your free result or paid credit unless a usable image is saved. Save ideas to your gallery, share watermarked concepts, create static body placement mockups, and unlock high-resolution exports with a design pack or optional Pro plan.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://example.com/inktrust-studio/privacy (privacy claims verified: no)
- **Data collected:** Tattoo prompt text and selected style/color/placement options; User-selected existing tattoo or reference photos for cover-up and image-to-tattoo workflows; Generated tattoo design images and placement mockup images; Anonymous Firebase user identifier; Purchase entitlement status for the high-resolution pack and Pro subscription

## Test plan

### 1. Trustless monetization: user should see one real free generation before any paywall (instrumented)

1. Install a fresh debug build and clear app data.
2. Launch the app and tap Start free tattoo brief on OnboardingTrustScreen.
3. Enter prompt 'small fine line mountain with moon', select fine-line style, black-and-gray color, and forearm placement.
4. Use a fake Firebase Functions implementation that returns one watermarked image URL for createGenerationJob.
5. Tap Generate, tap Generate anyway after critique, and wait for GenerationProgressScreen to navigate to ResultReviewScreen.

**Expected:** ResultReviewScreen is visible with populated_watermarked state, no PricingScreen was shown during the flow, and UserEntitlement.freeGenerationUsed is true only after the image is saved.

### 2. Failed generations and wasted attempts: failed generation must not spend the free result (unit)

1. Create a fake GenerationRepository whose createGenerationJob succeeds but whose pollGenerationJob returns failed with errorCode API_ERROR.
2. Create a fresh UserEntitlement object with freeGenerationUsed=false.
3. Run the generation worker logic for one LocalGenerationQueueItem with max attempts set to 3.
4. Query the in-memory Room database for GeneratedDesign and LocalGenerationQueueItem rows.

**Expected:** GeneratedDesign count is 0, LocalGenerationQueueItem.status is failed_refunded, and the fake entitlement still has freeGenerationUsed=false.

### 3. Failed generations and wasted attempts: transient failure then success should commit only once (unit)

1. Create a fake GenerationRepository where the first two poll attempts throw HTTP 500 exceptions and the third returns succeeded with one image URL.
2. Run the worker with a test dispatcher and backoff delays advanced virtually.
3. Record all fake credit ledger calls made by the worker.

**Expected:** The worker creates exactly one GeneratedDesign, records one commitGenerationSaved call, records zero refundFailedGeneration calls, and LocalGenerationQueueItem.attemptCount is 3.

### 4. Generic or weak prompt following: fine-line, color, placement, and symbols must be included in the assembled prompt (unit)

1. Create a TattooProject with rawPrompt 'fox and peony memorial piece', symbolsCsv 'fox,peony,initials', style 'fine_line', colorMode 'limited_color', placement 'wrist', and isCoverUp false.
2. Call the prompt assembly function.
3. Inspect the returned final prompt string.

**Expected:** The final prompt contains wrist placement, fine-line readable thin linework instruction, limited accent color instruction, the raw prompt, and all three symbols.

### 5. Missing tattoo workflows: cover-up intake should attach an existing tattoo photo without media permission (instrumented)

1. Launch BriefBuilderScreen and enable the Cover-up toggle.
2. Tap Add existing tattoo photo to open the Android Photo Picker.
3. Select a valid JPEG test image from the device media provider.
4. Tap Continue to critique.
5. Read the saved TattooProject from the test database.

**Expected:** TattooProject.isCoverUp is true, referencePhotoUri points to an app-specific copied file, and the app requested only INTERNET permission, not READ_MEDIA_IMAGES.

### 6. Missing tattoo workflows: body placement mockup must save an editable overlay result (instrumented)

1. Seed Room with one GeneratedDesign that points to a local tattoo image file.
2. Open ResultReviewScreen for that design and tap Try placement mockup.
3. Select a valid body photo through the Photo Picker.
4. Perform a pinch gesture, drag gesture, and opacity slider change on PlacementMockupScreen.
5. Tap Save mockup and return to GalleryScreen.

**Expected:** A PlacementMockup row exists with non-default scale or offset values, mockupImageUri is readable, and GalleryScreen shows the saved mockup thumbnail.

### 7. Trustless monetization: pricing and cancellation must be obvious (manual)

1. Open SettingsScreen and tap Pricing and purchases.
2. Verify the pricing page while signed into a test Google account with RevenueCat sandbox products configured.
3. Read the pack and Pro sections aloud against the screen copy.
4. Tap Restore purchases with no active sandbox purchase.

**Expected:** The screen visibly states first watermarked result is free, high-resolution pack is $4.99, Pro is $6.99/month, cancellation is through Google Play subscriptions, and restore returns a visible no-active-purchase style message instead of spinning indefinitely.

### 8. baseline parity: gallery export and watermarked sharing (manual)

1. Generate or seed a watermarked design and open ResultReviewScreen.
2. Tap Share watermarked and choose a local image target or file inspector app.
3. Open the shared image.
4. Return to the app, activate a test Pro entitlement, and tap Export high-resolution.

**Expected:** The shared image contains a visible InkTrust Studio watermark, and the high-resolution export writes an image to Pictures/InkTrust Studio without showing PricingScreen.

## Build instructions

```sh
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
keytool -genkeypair -v -keystore upload-keystore.jks -storepass changeit123 -keypass changeit123 -alias upload -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=InkTrust Studio,O=Solo Builder,C=US"
printf 'storeFile=upload-keystore.jks\nstorePassword=changeit123\nkeyAlias=upload\nkeyPassword=changeit123\n' > keystore.properties
./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
