# ClearRep Home Fitness — build spec

A no-equipment workout subscription that makes billing status visible, lets users cancel inside the app, preserves the catalog they paid for, and honors multiple injury/equipment limits with automatic substitutions.

## 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:** JustFit - Lazy Workout
- **Package id:** `fitness.home.workout.weight.loss`
- **Google Play:** https://play.google.com/store/apps/details?id=fitness.home.workout.weight.loss
- **appy.fyi report:** https://appy.fyi/report/fitness.home.workout.weight.loss
- **Category:** Health & Fitness

## Overview

- **Working name:** ClearRep Home Fitness (trademark cleared: no)
- **Package id:** `fyi.appy.clearrephomefitness`
- **Min / target SDK:** 26 / 35
- **Backend:** none
- **Estimated build time:** 12 weeks
- **Pricing:** subscription, $9.99 via `revenuecat`
- **Runtime AI:** yes (ai_gateway_llm): Propose a conflict-free exercise substitution when deterministic local matching cannot find one for the user’s selected injuries, equipment limits, goal, and skill level. ≈ $0.01/call
- **Permissions:** `INTERNET`

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

- No gym-equipment workout plans in v1; the report scopes the content library to no-equipment workouts.
- No social feed, trainer chat, meal planning, calorie tracking, or wearable integration in v1 because the report does not identify those as needed gap fixes.
- No silent server-driven removal of paid workout access; catalog changes must be versioned and visible.
- No promise that substitutions diagnose, treat, or medically rehabilitate injuries; they only avoid exercises conflicting with user-provided limitations.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Compose Navigation for all app routes | `androidx.navigation:navigation-compose:2.8.3` |
| Lifecycle-aware ViewModels used by Compose screens | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6` |
| Room local database for user profile, catalog entitlement snapshots, workout progress, and cached exercise content | `androidx.room:room-runtime:2.6.1` |
| Room Kotlin extensions for coroutines and Flow queries | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor for Kotlin Symbol Processing | `androidx.room:room-compiler:2.6.1` |
| Kotlin serialization for bundled exercise JSON, local catalog manifests, and AI gateway request/response bodies | `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3` |
| OkHttp client for AI gateway HTTPS calls | `com.squareup.okhttp3:okhttp:4.12.0` |
| RevenueCat purchases SDK for Play subscription purchase, entitlement status, and customer cancellation-management URL | `com.revenuecat.purchases:purchases:8.10.4` |
| Compose Material icons for in-app toolbar/action icons | `androidx.compose.material:material-icons-extended:1.7.4` |
| Coroutines test utilities for unit-testing personalization and catalog entitlement logic | `org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0` |

## Design system

- **Primary color:** `#256D5A`
- **Background color:** `#FAF7F0`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `barbell` (regular weight)
- **Theme notes:** Use Material 3 dynamic color disabled so screenshots and tests are deterministic. Light theme uses #FAF7F0 background, #256D5A primary, #1F1F1F on-background. Dark theme uses #121512 background, #7BC8AD primary, #F2F2F2 on-background. Workout cards use rounded 20dp corners and a single accent stripe colored by skill level: beginner #4E9F3D, intermediate #F4A261, advanced #D95D39.

## Screens

### Welcome
- **Route:** `welcome`
- **Purpose:** Introduce the honest-billing, no-equipment workout promise and send new users into setup or returning users to Home.
- **Reached via:** app launch when UserProfile does not exist
- **Key UI elements:** App logo and working name; One-sentence promise: no equipment, visible billing, cancel anytime; Primary button: Start setup; Secondary text link: I already set up this device
- **States:** populated

### SetupProfile
- **Route:** `setup/profile`
- **Purpose:** Collect workout goal, skill level, multiple injury flags, and available equipment before plan generation.
- **Reached via:** tap Start setup on Welcome; tap Edit profile on Settings
- **Key UI elements:** Goal selector with Home fitness and General weight loss options; Skill level segmented control: beginner, intermediate, advanced; Multiple-select injury checklist; Equipment selector defaulted to No equipment; Continue button disabled until goal and skill level are selected
- **States:** empty, validation_error, populated

### Paywall
- **Route:** `paywall`
- **Purpose:** Show the $9.99/month subscription terms, trial/cancellation honesty copy, and purchase entry point.
- **Reached via:** after SetupProfile completion; tap Subscribe from Home when not subscribed; tap Restore purchases from Settings
- **Key UI elements:** Price row: $9.99/month; Visible next-charge-date area that updates after purchase; Plain-language cancellation promise; Start subscription button; Restore purchases button; Purchase error panel that states no access is granted when billing fails
- **States:** loading_products, products_error, ready, purchasing, purchase_error, subscribed

### Home
- **Route:** `home`
- **Purpose:** Show today’s recommended no-equipment workout, subscription state, visible next charge date, and progress summary.
- **Reached via:** app launch when UserProfile exists; successful subscription purchase; bottom navigation Home tab
- **Key UI elements:** Subscription status banner with next charge date or unsubscribed state; Cancel/manage subscription button when subscribed; Today’s workout card; Progress summary card with streak and completed-workout count; Bottom navigation to Catalog, Progress, Settings
- **States:** loading, empty_no_profile, empty_no_subscription, error, populated

### Catalog
- **Route:** `catalog`
- **Purpose:** Browse all workouts and exercises available under the user’s current or grandfathered catalog entitlement.
- **Reached via:** bottom navigation Catalog tab; tap See all workouts from Home
- **Key UI elements:** Catalog version label; Filter chips for skill level and conflict-free for my profile; Workout list grouped by skill level; Empty-state text explaining if filters hide all workouts; Banner if a new catalog version exists and what changed
- **States:** loading, empty, error, populated

### WorkoutDetail
- **Route:** `workout/{workoutId}`
- **Purpose:** Preview a workout, identify any exercise conflicts, and start a session with substitutions already resolved.
- **Reached via:** tap workout in Catalog; tap Today’s workout card on Home
- **Key UI elements:** Workout title, skill level, and estimated duration; Exercise list with conflict badges for injury/equipment conflicts; Auto substitute button for any conflict; Start workout button disabled until conflicts are resolved or skipped; Explanation panel listing which user flags caused each conflict
- **States:** loading, not_found, needs_substitution, error, populated

### WorkoutSession
- **Route:** `session/{workoutId}`
- **Purpose:** Guide the user through the exercises, allow substitution/skip during the session, and record completion.
- **Reached via:** tap Start workout on WorkoutDetail
- **Key UI elements:** Current exercise name, instructions, duration or reps; Next exercise preview; Substitute exercise button; Skip exercise button with reason picker; Complete workout button; Exit confirmation dialog
- **States:** loading, active, substitution_loading, substitution_error, completed

### Progress
- **Route:** `progress`
- **Purpose:** Show workout completions, streaks, and recent history.
- **Reached via:** bottom navigation Progress tab; tap Progress summary card on Home
- **Key UI elements:** Current streak count; Total completed workouts; Calendar-style recent completion list; Workout history list with completed date and skipped count
- **States:** loading, empty, error, populated

### SubscriptionManage
- **Route:** `subscription`
- **Purpose:** Make billing status, renewal date, cancellation path, and restore flow visible at all times.
- **Reached via:** tap subscription banner on Home; tap Manage subscription in Settings; tap cancel/manage subscription button on Home
- **Key UI elements:** Current subscription state; Next charge date if active; Cancel subscription button; Restore purchases button; Last entitlement refresh timestamp; Cancellation-confirmed message when RevenueCat/Play reports inactive entitlement
- **States:** loading, active, inactive, restore_error, error

### Settings
- **Route:** `settings`
- **Purpose:** Provide profile editing, subscription management, privacy/legal links, and data reset.
- **Reached via:** bottom navigation Settings tab
- **Key UI elements:** Edit profile button; Manage subscription button; Restore purchases button; Privacy policy link; Reset local progress button with confirmation
- **States:** loading, error, populated

## Data model

### UserProfile (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key; always 1 for the single local profile |
| goal | `String` | enum string: HOME_FITNESS or GENERAL_WEIGHT_LOSS |
| skillLevel | `String` | enum string: BEGINNER, INTERMEDIATE, ADVANCED |
| injuryFlagsJson | `String` | JSON array of selected injury flag strings; supports more than one |
| equipmentFlagsJson | `String` | JSON array of available equipment strings; v1 defaults to NO_EQUIPMENT |
| createdAtEpochMillis | `Long` | UTC epoch milliseconds |
| updatedAtEpochMillis | `Long` | UTC epoch milliseconds |

### Exercise (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key from bundled catalog JSON |
| catalogVersion | `Int` | bundled catalog version containing this exercise |
| name | `String` |  |
| instructions | `String` |  |
| skillLevel | `String` | BEGINNER, INTERMEDIATE, or ADVANCED |
| goalTagsJson | `String` | JSON array of goal tags |
| requiredEquipmentJson | `String` | JSON array; empty means no equipment |
| contraindicationTagsJson | `String` | JSON array of injury/equipment limitation tags that conflict with this exercise |
| isActiveInBundledCatalog | `Boolean` | false only if a future bundled catalog deprecates it; entitlement snapshots may still show it |

### Workout (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key from bundled catalog JSON |
| catalogVersion | `Int` |  |
| title | `String` |  |
| skillLevel | `String` | BEGINNER, INTERMEDIATE, or ADVANCED |
| goalTagsJson | `String` | JSON array |
| estimatedMinutes | `Int` |  |
| exerciseIdsJson | `String` | ordered JSON array of Exercise.id values |
| isActiveInBundledCatalog | `Boolean` | false only if a future bundled catalog deprecates it; entitlement snapshots may still show it |

### CatalogEntitlementSnapshot (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| revenueCatAppUserId | `String` | nullable if user has not subscribed |
| entitledCatalogVersion | `Int` | catalog version captured at first active subscription |
| entitledWorkoutIdsJson | `String` | JSON array of all Workout.id values visible at capture time |
| entitledExerciseIdsJson | `String` | JSON array of all Exercise.id values visible at capture time |
| capturedAtEpochMillis | `Long` | UTC epoch milliseconds |

### SubscriptionStatusCache (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key; always 1 |
| isActive | `Boolean` | latest RevenueCat entitlement state |
| nextChargeAtEpochMillis | `Long` | nullable; parsed from RevenueCat/Store product period when available |
| managementUrl | `String` | nullable RevenueCat customerInfo.managementURL |
| lastRefreshAtEpochMillis | `Long` | UTC epoch milliseconds |

### WorkoutCompletion (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| workoutId | `String` | foreign key to Workout.id |
| completedAtEpochMillis | `Long` | UTC epoch milliseconds |
| completedExerciseIdsJson | `String` | ordered JSON array |
| skippedExerciseIdsJson | `String` | ordered JSON array |
| substitutionPairsJson | `String` | JSON array of objects {originalExerciseId, substituteExerciseId, reason} |

### AiSubstitutionCache (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| cacheKey | `String` | primary key; SHA-256 of originalExerciseId + sorted injury flags + sorted equipment flags + goal + skillLevel |
| originalExerciseId | `String` | foreign key to Exercise.id |
| substituteExerciseId | `String` | foreign key to Exercise.id |
| reason | `String` | short user-visible explanation returned or generated after validation |
| createdAtEpochMillis | `Long` | UTC epoch milliseconds |

## Features

### No-equipment workout content library

Provide a bundled library of no-equipment exercises and workouts across multiple skill levels for personalization and catalog browsing.

- **Answers complaint:** baseline parity

- **Screens:** Catalog, WorkoutDetail, WorkoutSession

- **Estimated hours:** 120

**Implementation notes:** Ship `app/src/main/assets/catalog_v1.json` containing exercises and workouts with stable IDs, skill level, goal tags, requiredEquipment, contraindicationTags, instructions, and workout exercise ordering. On first launch, compute the bundled catalog version from the JSON top-level `version` integer, upsert all Exercise and Workout rows into Room inside a transaction, and set `isActiveInBundledCatalog=true` for IDs present in the JSON. Every exercise in v1 must have `requiredEquipment` empty or `NO_EQUIPMENT`; reject the import in a unit-tested parser if any v1 item requires gym equipment.

**Acceptance criteria:**
- A fresh install imports catalog version 1 from assets into Room before Catalog leaves loading state.
- Catalog shows workouts grouped by beginner, intermediate, and advanced skill levels when no filters are applied.
- The catalog parser throws a validation error if a v1 exercise declares required equipment other than NO_EQUIPMENT or an empty array.
- Tapping a workout opens WorkoutDetail with the workout title, estimated minutes, and ordered exercise list.

### Multi-injury and equipment-aware profile setup

Let users select more than one injury limitation and their available equipment so personalization can avoid conflicts.

- **Answers complaint:** Injuries and equipment gaps ignored

- **Screens:** SetupProfile, Home, Settings

- **Estimated hours:** 48

**Implementation notes:** Implement SetupProfile with Compose state backed by a `ProfileDraft` data class. Injury choices must be a multi-select checklist, not radio buttons; persist all selected values as a sorted JSON array in `UserProfile.injuryFlagsJson`. Equipment selection defaults to `NO_EQUIPMENT`; store selected equipment as sorted JSON in `equipmentFlagsJson`. The Continue button writes the profile to Room and triggers plan recommendation by querying workouts matching `goalTagsJson` and `skillLevel`, then ranking conflict-free workouts before workouts that need substitutions.

**Acceptance criteria:**
- A user can select at least two injury flags at the same time and both remain selected after rotating the device.
- Saving a profile with two injury flags stores a JSON array containing both flags in Room.
- When equipment is left unchanged, the saved equipment array contains NO_EQUIPMENT.
- Home recommendation prefers a workout with zero contraindication conflicts over a workout with one or more conflicts for the same goal and skill level.

### Automatic conflict detection and substitutions

Detect exercises that conflict with selected injuries/equipment and replace them instead of only letting the user skip them.

- **Answers complaint:** Injuries and equipment gaps ignored

- **Screens:** WorkoutDetail, WorkoutSession

- **Estimated hours:** 80

**Implementation notes:** Create `ConflictDetector` that compares each Exercise.requiredEquipment against `UserProfile.equipmentFlagsJson` and each Exercise.contraindicationTagsJson against `UserProfile.injuryFlagsJson`; a conflict exists if required equipment is unavailable or any contraindication tag is selected. For substitution, first search the local Exercise table for the same skill level and overlapping goal tag where requiredEquipment is satisfied and contraindicationTags has no intersection with injury flags. If multiple candidates match, choose the one with the most overlapping goal tags, then lexical ID order for determinism. If no local candidate exists, call the AI gateway with original exercise metadata, sorted injury flags, sorted equipment flags, goal, skill level, and a compact list of locally available candidate exercises; accept the response only if the returned substituteExerciseId exists locally and passes the same deterministic `ConflictDetector`. Cache accepted substitutions in `AiSubstitutionCache`; if AI fails or returns an invalid ID, show `substitution_error` and allow explicit skip with reason.

**Acceptance criteria:**
- WorkoutDetail displays a conflict badge beside every exercise whose contraindication tags intersect the saved injury flags.
- Start workout is disabled while unresolved conflicts remain.
- Tapping Auto substitute replaces the conflicting exercise with a local conflict-free candidate when one exists.
- If the AI gateway returns a substitute with a contraindication tag matching the user profile, the app rejects it and does not add it to the workout.
- A successfully accepted AI substitute is stored in AiSubstitutionCache and reused on the next identical request without another network call.

### Honest subscription purchase screen

Show $9.99/month terms clearly and do not unlock content or generate a paid plan when billing returns an error.

- **Answers complaint:** Charged without real consent

- **Screens:** Paywall, Home

- **Estimated hours:** 48

**Implementation notes:** Use RevenueCat Purchases SDK configured with one monthly entitlement named `pro_monthly`. Paywall loads offerings with `Purchases.sharedInstance.getOfferings`; render exactly the package whose store product period is monthly and price string corresponds to the configured $9.99/month product. On purchase, call `purchaseWith` and wait for CustomerInfo; set `SubscriptionStatusCache.isActive=true` only if CustomerInfo entitlements contains active `pro_monthly`. If RevenueCat returns an error or user cancellation, leave entitlement inactive, show the exact purchase_error panel, and do not create a CatalogEntitlementSnapshot. Never navigate to Home as subscribed until the active entitlement is observed.

**Acceptance criteria:**
- Paywall displays $9.99/month before the purchase button is enabled.
- If the mocked purchase call returns an error, SubscriptionStatusCache remains inactive.
- If the mocked purchase call returns an error, the app shows purchase_error text and does not navigate to subscribed Home.
- If CustomerInfo contains active pro_monthly entitlement, Home shows subscribed state and catalog entitlement snapshot is created exactly once.

### Visible next-charge date and self-serve cancellation

Keep subscription status, renewal date, and cancellation path visible inside the app at all times.

- **Answers complaint:** Charged without real consent

- **Screens:** Home, SubscriptionManage, Settings

- **Estimated hours:** 32

**Implementation notes:** On app foreground and when SubscriptionManage opens, call `Purchases.sharedInstance.getCustomerInfo`. Cache active/inactive status, CustomerInfo managementURL, and the active entitlement expiration/renewal date if provided by RevenueCat. Home must always render either `Next charge: <localized date>` for active subscriptions with a date, `Subscription active: renewal date unavailable` for active subscriptions without a date, or `No active subscription`. The Cancel button opens `managementUrl` via `Intent.ACTION_VIEW`; if it is null, open `https://play.google.com/store/account/subscriptions`. After returning from the browser/Play subscription page, refresh CustomerInfo and show inactive/cancellation-confirmed state once entitlement is no longer active.

**Acceptance criteria:**
- Home shows a subscription status banner on every populated render.
- When cached nextChargeAtEpochMillis is present, Home formats and displays that date.
- Tapping Cancel subscription launches the RevenueCat management URL when present.
- If management URL is null, tapping Cancel subscription launches the Play subscriptions account URL.
- After mocked CustomerInfo changes from active to inactive, SubscriptionManage displays inactive/cancellation-confirmed state.

### Grandfathered catalog entitlement

Preserve the workout and exercise catalog a subscriber first paid for even if later bundled catalog versions remove items.

- **Answers complaint:** Catalog quietly shrinks for paying subscribers

- **Screens:** Catalog, WorkoutDetail, Home

- **Estimated hours:** 40

**Implementation notes:** When `pro_monthly` first becomes active, create a single CatalogEntitlementSnapshot if none exists for the local RevenueCat app user ID. Snapshot all Workout and Exercise IDs where `isActiveInBundledCatalog=true` at that moment. Catalog queries for subscribed users must use the union of currently active bundled IDs plus snapshot IDs; never hide a snapshot item simply because a later catalog import marks it inactive. If a future bundled catalog version removes an item, keep its last imported Room row and display it with a `Grandfathered` label. Catalog must show the current bundled catalog version and the user’s entitled version.

**Acceptance criteria:**
- First active subscription creates one CatalogEntitlementSnapshot containing all currently active workout IDs.
- Re-importing a later test catalog that marks a snapshot workout inactive does not remove that workout from the subscribed user’s Catalog list.
- A grandfathered workout opens WorkoutDetail successfully using its preserved Room row.
- Catalog displays both the current bundled catalog version and the entitled catalog version for subscribed users.

### Workout session tracking, completions, and streaks

Record completed workouts, skipped exercises, substitutions, and show streak/progress summaries.

- **Answers complaint:** baseline parity

- **Screens:** WorkoutSession, Progress, Home

- **Estimated hours:** 60

**Implementation notes:** WorkoutSession maintains an ordered in-memory session list of exercise IDs after substitutions. On Complete workout, insert WorkoutCompletion with completedExerciseIdsJson, skippedExerciseIdsJson, and substitutionPairsJson. Progress streak is computed from distinct local dates with at least one WorkoutCompletion, counting backward from today if today has a completion, otherwise from yesterday. Use java.time LocalDate with the device ZoneId for display and streak grouping. The Progress screen queries completions newest-first and computes total completed workouts from row count.

**Acceptance criteria:**
- Completing a workout inserts exactly one WorkoutCompletion row.
- A completion with one skipped exercise stores that exercise ID in skippedExerciseIdsJson.
- A completion after substitution stores the original and substitute exercise IDs in substitutionPairsJson.
- Two consecutive local-date completions produce a streak count of 2.
- Progress empty state is shown when WorkoutCompletion table has zero rows.

### Restore purchases and entitlement refresh

Let users restore subscription status without support contact and keep the cached entitlement current.

- **Answers complaint:** Charged without real consent

- **Screens:** Paywall, SubscriptionManage, Settings

- **Estimated hours:** 24

**Implementation notes:** Add Restore purchases buttons on Paywall, SubscriptionManage, and Settings. The button calls `Purchases.sharedInstance.restorePurchases`, then updates SubscriptionStatusCache using the same active entitlement extraction used after purchase. If active entitlement is restored and no CatalogEntitlementSnapshot exists, create the snapshot from current active bundled catalog IDs. If restore fails, render restore_error with RevenueCat error message sanitized to a user-readable sentence and keep previous cached status unchanged.

**Acceptance criteria:**
- Restore purchases appears on Paywall, SubscriptionManage, and Settings.
- Mocked restored CustomerInfo with active pro_monthly sets SubscriptionStatusCache.isActive to true.
- Mocked restore error leaves the prior SubscriptionStatusCache row unchanged.
- When restore activates a subscription and no snapshot exists, CatalogEntitlementSnapshot is created once.

### Store polish and safety copy

Package the app with clear subscription, privacy, and injury-limitation language for release readiness.

- **Answers complaint:** baseline parity

- **Screens:** Welcome, Paywall, SetupProfile, Settings

- **Estimated hours:** 28

**Implementation notes:** Add in-app copy on Welcome, Paywall, SetupProfile, and Settings stating: workouts are no-equipment, subscription is $9.99/month, cancellation is managed through the visible subscription screen, and injury flags are used only to avoid selected movements rather than provide medical treatment. Add a Settings privacy-policy link using the URL in this spec. Ensure all screens have stable content descriptions on primary buttons for instrumented tests.

**Acceptance criteria:**
- Paywall contains the exact text `$9.99/month`.
- Settings contains a visible Privacy policy link.
- SetupProfile contains text explaining injury flags are used to avoid selected movements.
- Primary buttons on Welcome, SetupProfile, Paywall, and Settings have non-empty content descriptions.

## Store listing

- **Title:** ClearRep Home Fitness
- **Short description:** No-equipment workouts with honest billing and injury-aware swaps.
- **Category:** Health & Fitness
- **Keywords:** home workout, no equipment workout, fitness, injury aware workouts, bodyweight exercise, workout tracker, subscription fitness
- **Icon prompt:** Create a clean Android app icon for a no-equipment home fitness app. Use a rounded square background in warm off-white #FAF7F0, a centered simple barbell/bodyweight fitness glyph in deep green #256D5A, minimal flat vector style, no text, no gradients, high contrast, suitable for Play Store launcher icon.

**Long description:**

ClearRep Home Fitness is a no-equipment workout app built around trust. Set your goal, skill level, injury limitations, and available equipment, then follow home workouts that avoid movements you flagged. If an exercise conflicts with your profile, ClearRep replaces it with a better match instead of only asking you to skip it.

The subscription is $9.99/month. Your subscription status and next charge date are visible inside the app, and the subscription management screen gives you a clear cancellation path. The catalog you subscribe to is preserved with a local entitlement snapshot so paid access is not silently reduced by later catalog changes.

ClearRep is for general fitness guidance. Injury flags help avoid selected movements; they are not medical diagnosis, treatment, or rehabilitation advice.

## Legal

- **Regulated category:** health
- **Privacy policy URL:** https://clearrep.example.com/privacy (privacy claims verified: no)
- **Data collected:** fitness goal; skill level; injury limitation flags; available equipment flags; workout completion history; skipped exercise history; exercise substitution history; subscription status; purchase identifiers handled by RevenueCat; AI substitution request contents containing exercise metadata and selected injury/equipment flags

## Test plan

### 1. Injuries and equipment gaps ignored: user can flag only one injury when they have two (unit)

1. Create a ProfileDraft with goal HOME_FITNESS and skillLevel BEGINNER.
2. Add injury flags KNEE_LIMITATION and WRIST_LIMITATION to the draft multi-select set.
3. Save the draft through ProfileRepository.
4. Read UserProfile id 1 from the in-memory Room database.
5. Decode injuryFlagsJson as a JSON string array.

**Expected:** The decoded injury array contains both KNEE_LIMITATION and WRIST_LIMITATION, and its size is 2.

### 2. Injuries and equipment gaps ignored: app assigns exercises that contradict what the user said they cannot do (unit)

1. Insert an Exercise with id pushup, contraindicationTagsJson containing WRIST_LIMITATION, and requiredEquipmentJson empty.
2. Insert a UserProfile with injuryFlagsJson containing WRIST_LIMITATION and equipmentFlagsJson containing NO_EQUIPMENT.
3. Run ConflictDetector.detect on the pushup exercise and the user profile.

**Expected:** ConflictDetector returns a conflict whose reason includes WRIST_LIMITATION.

### 3. Injuries and equipment gaps ignored: cannot swap out a flagged exercise instead of just skipping it (unit)

1. Insert original Exercise id pushup with goal tag HOME_FITNESS and contraindicationTagsJson containing WRIST_LIMITATION.
2. Insert candidate Exercise id squat with goal tag HOME_FITNESS, same skill level, empty requiredEquipmentJson, and empty contraindicationTagsJson.
3. Insert UserProfile with injuryFlagsJson containing WRIST_LIMITATION and equipmentFlagsJson containing NO_EQUIPMENT.
4. Call SubstitutionEngine.substitute for pushup.

**Expected:** SubstitutionEngine returns substituteExerciseId squat and marks the source as LOCAL_DETERMINISTIC.

### 4. Injuries and equipment gaps ignored: AI substitution must not validate an unsafe conflicting replacement (unit)

1. Insert original Exercise id pushup with contraindicationTagsJson containing WRIST_LIMITATION.
2. Insert candidate Exercise id plank with contraindicationTagsJson containing WRIST_LIMITATION.
3. Mock the AI gateway response to return substituteExerciseId plank.
4. Call SubstitutionEngine.substitute for a profile containing WRIST_LIMITATION.

**Expected:** The engine rejects plank, returns a substitution error, and no AiSubstitutionCache row is inserted.

### 5. Catalog quietly shrinks for paying subscribers: never silently reduce what an existing subscriber already has access to (unit)

1. Import catalog version 1 containing workout ids w1 and w2.
2. Create an active subscription entitlement snapshot for the local RevenueCat app user id.
3. Import catalog version 2 where w2 is present in Room but isActiveInBundledCatalog is false.
4. Query CatalogRepository.visibleWorkouts for a subscribed user.

**Expected:** The visible workout IDs include both w1 and w2, and w2 is marked as grandfathered.

### 6. Charged without real consent: purchase error says user would not be charged but charge/access state is inconsistent (unit)

1. Mock RevenueCat purchaseWith to return a non-user-cancelled purchase error.
2. Start PaywallViewModel purchase flow.
3. Observe PaywallUiState after the purchase call completes.
4. Read SubscriptionStatusCache and CatalogEntitlementSnapshot count from the in-memory Room database.

**Expected:** PaywallUiState is purchase_error, SubscriptionStatusCache.isActive is false or absent, and CatalogEntitlementSnapshot count is 0.

### 7. Charged without real consent: locked into a new plan by a single misplaced tap with no easy way back (instrumented)

1. Launch the app with an existing active SubscriptionStatusCache containing a future nextChargeAtEpochMillis and a non-null managementUrl.
2. Navigate to Home.
3. Assert that the subscription status banner is displayed.
4. Tap the banner to open SubscriptionManage.
5. Tap Cancel subscription.

**Expected:** An ACTION_VIEW intent is fired for the stored managementUrl, proving cancellation is reachable from inside the app.

### 8. Charged without real consent: visible next-charge date inside the app at all times (instrumented)

1. Seed SubscriptionStatusCache with isActive true and nextChargeAtEpochMillis for 2030-01-15T00:00:00Z.
2. Launch Home.
3. Wait until Home reaches populated state.

**Expected:** Home displays a subscription banner containing `Next charge:` and the localized date for January 15, 2030.

### 9. Baseline parity: setup leads to a browsable no-equipment workout catalog (instrumented)

1. Clear app data.
2. Launch Welcome.
3. Tap Start setup.
4. Select HOME_FITNESS goal.
5. Select BEGINNER skill level.
6. Leave equipment as NO_EQUIPMENT.
7. Tap Continue.
8. Navigate to Catalog.

**Expected:** Catalog reaches populated state and displays at least one workout card whose detail screen contains only exercises with no required equipment.

### 10. Charged without real consent: cancellation confirmed by store should stop active entitlement display (manual)

1. Install a release build connected to a Play Billing test subscription product configured at $9.99/month.
2. Purchase the monthly subscription from Paywall using a license tester account.
3. Open Home and confirm the next-charge banner is visible.
4. Open SubscriptionManage and tap Cancel subscription.
5. Complete cancellation in the Play subscription management page.
6. Return to the app and reopen SubscriptionManage after RevenueCat refreshes CustomerInfo.

**Expected:** SubscriptionManage shows inactive/cancellation-confirmed state and Home no longer shows an active next-charge banner.

## Build instructions

```sh
export ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk}
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
./gradlew bundleRelease
```

## Human gates still required

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