# Duet Daily — build spec

A daily couples-question app that makes partner sync, fresh prompts, fair streaks, and subscription cancellation honest instead of using streaks and billing friction as leverage.

## 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:** Paired: Couples & Relationship
- **Package id:** `com.getpaired.app`
- **Google Play:** https://play.google.com/store/apps/details?id=com.getpaired.app
- **appy.fyi report:** https://appy.fyi/report/com.getpaired.app
- **Category:** Lifestyle

## Overview

- **Working name:** Duet Daily (trademark cleared: no)
- **Package id:** `fyi.appy.duetdaily`
- **Min / target SDK:** 26 / 35
- **Backend:** firebase
- **Estimated build time:** 6 weeks
- **Pricing:** subscription, $6.99 via `revenuecat`
- **Runtime AI:** yes (ai_gateway_llm): Generate 60 personalized relationship prompts for a couple when their paid personalized queue drops below 14 unused prompts. ≈ $0.02/call
- **Permissions:** `INTERNET`, `POST_NOTIFICATIONS`

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

- No paid streak-freeze purchases or any second paywall to preserve an existing habit.
- No dark-pattern cancellation flow, retention modal, hidden settings entry, or button labeled like keeping a subscription when it starts cancellation.
- No couples therapy, mental-health diagnosis, crisis support, or medical relationship advice claims.
- No public social feed, friend graph, group chats, video/audio calling, or dating discovery.
- No large browseable course library for v1 beyond daily prompts, prompt history, and generated prompt queues.
- No child-directed positioning or under-13 account support.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Compose host activity integration | `androidx.activity:activity-compose:1.9.3` |
| Material 3 Compose components | `androidx.compose.material3:material3:1.3.0` |
| Compose UI runtime and primitives | `androidx.compose.ui:ui:1.7.5` |
| Compose preview tooling hooks | `androidx.compose.ui:ui-tooling-preview:1.7.5` |
| Compose navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| ViewModel integration for Compose screens | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6` |
| Lifecycle-aware collection of StateFlow in Compose | `androidx.lifecycle:lifecycle-runtime-compose:2.8.6` |
| Android Kotlin extensions and notification compatibility helpers | `androidx.core:core-ktx:1.13.1` |
| Firebase email/password authentication for two partner accounts | `com.google.firebase:firebase-auth-ktx:23.1.0` |
| Firestore realtime partner sync and prompt/answer/streak storage | `com.google.firebase:firebase-firestore-ktx:25.1.1` |
| Callable Firebase Function invocation for AI prompt generation without exposing an LLM key in the app | `com.google.firebase:firebase-functions-ktx:21.1.0` |
| Kotlin coroutine await support for Firebase Task APIs | `org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0` |
| RevenueCat subscription entitlement and purchase flow for the $6.99/month plan | `com.revenuecat.purchases:purchases:8.9.0` |
| Local persistence for last selected tab, notification prompt dismissal, and cached app-start routing state | `androidx.datastore:datastore-preferences:1.1.1` |
| Daily reminder scheduling that survives app process death | `androidx.work:work-runtime-ktx:2.9.1` |
| Kotlin JSON serialization for Firebase Function request/response payloads | `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3` |

## Design system

- **Primary color:** `#7C3AED`
- **Background color:** `#FFF8F3`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale using the platform sans-serif font; no custom downloadable font.
- **Launcher icon glyph:** Phosphor `chat-centered-text` (regular weight)
- **Theme notes:** Use a warm, calm couples-journal style. Light theme background is #FFF8F3 with white cards; dark theme background is #18121F with cards #241A2E. Primary buttons use #7C3AED in both themes, disabled buttons use 38% alpha, and destructive/cancel actions use text-only buttons except the honest subscription cancel button, which uses an outlined button with #B3261E text.

## Screens

### Auth
- **Route:** `auth`
- **Purpose:** Create or sign in to the user's account before partner pairing and sync.
- **Reached via:** app launch when no Firebase user is signed in; tap Sign out on Settings & Billing
- **Key UI elements:** Email text field; Password text field; Sign in button; Create account button; Inline error text; Privacy and billing honesty note
- **States:** loading existing Firebase session, signed out form, submitting, error

### Onboarding Profile
- **Route:** `onboarding/profile`
- **Purpose:** Collect the couple-context inputs used for prompt categorization and AI prompt generation.
- **Reached via:** successful account creation; app launch when signed-in user has no completed profile; tap Edit profile on Settings & Billing
- **Key UI elements:** Display name field; Relationship stage single-choice chips; Interest multi-select chips; Continue button; Skip interests text button
- **States:** loading saved profile, empty first-run form, editing populated profile, saving, error

### Pairing
- **Route:** `pairing`
- **Purpose:** Let two accounts join the same couple using an invite code and show pairing status.
- **Reached via:** after Onboarding Profile; app launch when signed-in user has no coupleId; tap Partner pairing on Settings & Billing
- **Key UI elements:** Current user invite code card; Copy invite code button; Partner code entry field; Join partner button; Waiting for partner message; Paired partner summary
- **States:** loading current couple state, not paired with active invite code, joining code, paired populated, invalid or expired code error

### Today
- **Route:** `today`
- **Purpose:** Show today's available relationship prompts, streak status, and answer progress for both partners.
- **Reached via:** app launch when signed in, profiled, and paired; bottom navigation Today tab; successful pairing
- **Key UI elements:** Top app bar with Settings button; Streak count chip; Subscription/free-tier status chip; List of today's prompt cards; Generate fresh prompts progress banner; Answered-by-me and answered-by-partner indicators; Open prompt button
- **States:** loading today data, empty while prompt generation is queued, error loading prompts, populated with unanswered prompts, populated all answered for today

### Answer Prompt
- **Route:** `prompt/{promptId}`
- **Purpose:** Display one prompt and let the signed-in partner submit or edit their answer for the day.
- **Reached via:** tap a prompt card on Today; tap a prompt in Streak & History
- **Key UI elements:** Prompt text; Category label; Partner answered status; Multiline answer field; Save answer button; Back to Today button
- **States:** loading prompt and existing answer, ready unanswered, ready with saved answer, saving, error

### Streak & History
- **Route:** `streak`
- **Purpose:** Show streak history, missed-day repair, and past answered prompts without selling streak freezes.
- **Reached via:** bottom navigation Streak tab; tap streak count chip on Today
- **Key UI elements:** Current streak card; Longest streak card; Calendar-style recent day list; Repair missed day card; Past prompt list; No paid freezes explanation
- **States:** loading streak and history, empty no answered days yet, populated active streak, populated missed day repair available, populated missed day repair unavailable, error

### Subscribe
- **Route:** `subscribe`
- **Purpose:** Explain the optional $6.99/month subscription and start purchase without hiding free functionality.
- **Reached via:** tap subscription/free-tier chip on Today; tap Manage plan on Settings & Billing; hit a paid-only personalization limit
- **Key UI elements:** Free tier included list; Paid tier included list; $6.99/month price row; Subscribe button; Not now button; Restore purchases button; No streak paywalls statement
- **States:** loading offering, free user with offering populated, purchase in progress, subscriber already active, purchase error, restore result

### Settings & Billing
- **Route:** `settings`
- **Purpose:** Provide a findable settings hub with an explicit one-tap subscription cancellation entry.
- **Reached via:** tap Settings button on Today; system back from Notification Settings; system back from Subscribe
- **Key UI elements:** Profile summary row; Partner pairing row; Notification settings row; Manage plan row; Cancel subscription now button; Restore purchases button; Sign out button
- **States:** loading user and entitlement, populated free user, populated active subscriber, cancellation link opening, restore in progress, error

### Notification Settings
- **Route:** `settings/notifications`
- **Purpose:** Let the couple choose a daily reminder time and request Android notification permission transparently.
- **Reached via:** tap Notification settings row on Settings & Billing
- **Key UI elements:** Enable reminders switch; Reminder time picker; Permission rationale card; Request notification permission button; Save button
- **States:** loading saved preference, permission not requested, permission granted populated, permission denied populated, saving, error

## Data model

### UserProfile (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | Firebase Auth uid; document id in users/{id} |
| email | `String` | email used for Firebase Auth sign-in |
| displayName | `String` | shown to partner on pairing and answer status |
| relationshipStage | `String` | one of dating, engaged, married, long_distance, rebuilding, prefer_not_to_say |
| interests | `List<String>` | selected interest tags used for prompt categorization and AI generation |
| coupleId | `String?` | nullable until paired; foreign key to Couple.id |
| notificationHour | `Int` | 0-23 local hour for daily reminder |
| notificationMinute | `Int` | 0-59 local minute for daily reminder |
| createdAtMillis | `Long` | server timestamp converted to epoch millis |
| updatedAtMillis | `Long` | server timestamp converted to epoch millis |

### Couple (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | document id in couples/{id} |
| partnerUserIds | `List<String>` | exactly two Firebase Auth uids after pairing; one uid while waiting |
| inviteCode | `String` | 8-character uppercase alphanumeric code generated by the first partner |
| inviteCodeExpiresAtMillis | `Long` | epoch millis; reject joins after this time |
| status | `String` | waiting or paired |
| createdAtMillis | `Long` | server timestamp converted to epoch millis |

### Prompt (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | document id in prompts/{id} |
| coupleId | `String?` | nullable for seeded global free prompts; set for AI-personalized prompts |
| text | `String` | question shown to both partners |
| category | `String` | communication, memories, future, intimacy, fun, conflict, gratitude |
| source | `String` | seed or ai_gateway_llm |
| tier | `String` | free or paid_personalized |
| availableDate | `String` | ISO-8601 local date YYYY-MM-DD for daily scheduling |
| createdAtMillis | `Long` | server timestamp converted to epoch millis |

### DailyAnswer (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | compound key {coupleId}_{promptId}_{userId}_{answeredForDate} |
| coupleId | `String` | foreign key to Couple.id |
| promptId | `String` | foreign key to Prompt.id |
| userId | `String` | Firebase Auth uid of answering partner |
| answerText | `String` | plain text answer entered by user |
| answeredForDate | `String` | ISO-8601 local date YYYY-MM-DD used for streak calculation |
| createdAtMillis | `Long` | server timestamp converted to epoch millis |
| updatedAtMillis | `Long` | server timestamp converted to epoch millis |

### StreakState (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| coupleId | `String` | primary key; document id in streaks/{coupleId} |
| currentCount | `Int` | number of consecutive completed local dates |
| longestCount | `Int` | maximum currentCount ever reached |
| lastCompletedDate | `String?` | nullable before first completed day; ISO-8601 local date |
| repairCreditsMonth | `String` | YYYY-MM month for repair credit counter reset |
| repairCreditsUsedCount | `Int` | 0-3; repairs are not purchasable |
| updatedAtMillis | `Long` | server timestamp converted to epoch millis |

### SubscriptionEntitlement (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| userId | `String` | primary key; Firebase Auth uid |
| isActive | `Boolean` | true when RevenueCat entitlement for monthly subscription is active |
| productId | `String` | monthly subscription product id, duet_daily_monthly |
| renewsAtMillis | `Long?` | nullable when free or expired |
| source | `String` | revenuecat |
| updatedAtMillis | `Long` | server timestamp converted to epoch millis |

### PromptGenerationJob (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | document id in promptGenerationJobs/{id} |
| coupleId | `String` | foreign key to Couple.id |
| status | `String` | queued, running, completed, failed |
| requestedPromptCount | `Int` | normally 60 when personalized queue drops below threshold |
| generatedPromptCount | `Int` | 0 until completed |
| errorMessage | `String?` | nullable unless status is failed |
| requestedAtMillis | `Long` | server timestamp converted to epoch millis |
| completedAtMillis | `Long?` | nullable until completed |

## Features

### Account setup and relationship profile

Users create/sign in to an account and save relationship stage plus interests so daily prompts can sync and be personalized.

- **Answers complaint:** baseline parity

- **Screens:** Auth, Onboarding Profile

- **Estimated hours:** 30

**Implementation notes:** Use FirebaseAuth email/password. On app start, observe FirebaseAuth.currentUser; if null navigate to auth, if profile document users/{uid} is missing or lacks relationshipStage navigate to onboarding/profile, otherwise continue to pairing or today. Store relationshipStage as a fixed enum string and interests as a list of selected tag strings in Firestore. Validate email with Android Patterns.EMAIL_ADDRESS, require password length >= 8, and show FirebaseAuthException.localizedMessage in the Auth error state. Do not request partner data or subscription status until after auth succeeds.

**Acceptance criteria:**
- A new user can create an account with a valid email and 8-character password and is routed to Onboarding Profile.
- An existing user can sign in and is not asked to re-enter profile data if users/{uid}.relationshipStage exists.
- Submitting an invalid email keeps the user on Auth and displays inline error text without calling FirebaseAuth.
- After saving profile, Firestore users/{uid} contains displayName, relationshipStage, interests, createdAtMillis, and updatedAtMillis.

### Partner pairing and realtime couple sync

Two accounts join the same couple with an invite code and then see shared couple state in realtime.

- **Answers complaint:** baseline parity

- **Screens:** Pairing, Today, Settings & Billing

- **Estimated hours:** 36

**Implementation notes:** When the first partner reaches Pairing, create couples/{autoId} with partnerUserIds=[uid], status='waiting', inviteCode=random 8 uppercase base36 characters, inviteCodeExpiresAtMillis=now+7 days, then write users/{uid}.coupleId. For Join, query couples where inviteCode equals entered uppercase code and status='waiting', reject if expired or if the document already contains the joining uid, then run a Firestore transaction that appends the second uid and sets status='paired'. Both clients attach snapshot listeners to users/{uid} and couples/{coupleId}; when status becomes paired, navigate to Today. If a code is invalid or expired, show the invalid/expired error state and keep the current user's waiting invite code active.

**Acceptance criteria:**
- Partner A sees an 8-character invite code after opening Pairing for the first time.
- Partner B entering Partner A's unexpired code causes both users' users/{uid}.coupleId to reference the same couple document.
- Both devices route to Today within one Firestore snapshot update after the transaction commits.
- Entering a nonexistent or expired code shows an error and does not overwrite the joining user's existing coupleId.

### Useful free daily prompts

Free users receive multiple daily prompt choices and full answer/history access so the app is not a one-question bait trial.

- **Answers complaint:** "Free" is nearly unusable

- **Screens:** Today, Answer Prompt, Streak & History, Subscribe

- **Estimated hours:** 34

**Implementation notes:** Seed at least 210 global free Prompt documents before release, spread across the seven categories. For each local date, the Today ViewModel selects three free prompts by hashing coupleId + ISO date, sorting seeded free prompts by the hash distance, and taking three prompts that have not been answered by either partner in the last 90 days. Free users can open, answer, edit, and view history for all three daily prompts. Paid status only unlocks AI-personalized prompt generation and extra generated categories; it must not block today's three free prompts, answer saving, partner answer status, history, or streak calculation.

**Acceptance criteria:**
- A non-subscriber paired couple sees exactly three available prompt cards on Today for a new date when at least three seeded prompts exist.
- A non-subscriber can save an answer and later open it from Streak & History without seeing Subscribe.
- The Subscribe screen lists what remains free before listing paid benefits.
- No UI copy says the app is free while hiding answer saving, history, or streaks behind the subscription.

### AI-backed fresh prompt queue

The app generates a large personalized prompt queue before couples run out of daily questions.

- **Answers complaint:** Streaks become a monetization lever

- **Screens:** Today, Subscribe

- **Estimated hours:** 34

**Implementation notes:** Maintain a per-couple count of unused personalized Prompt documents with source='ai_gateway_llm' and availableDate >= today. When an active subscriber's unused personalized count drops below 14, create PromptGenerationJob with requestedPromptCount=60 and call FirebaseFunctions.getHttpsCallable('generateRelationshipPrompts'). The callable receives coupleId, relationshipStage, interests, alreadyUsedPromptTexts from the last 180 days, and requestedPromptCount. The function must call the configured AI gateway LLM, request JSON array output with fields text and category, reject duplicates by normalized lowercase text, reject prompts longer than 180 characters, and write accepted prompts to prompts with tier='paid_personalized'. The Android client displays a non-blocking generation banner while job status is queued/running; if the function fails, Today falls back to seeded free prompts and shows an error banner rather than blocking streak progress.

**Acceptance criteria:**
- When an active subscriber has 13 unused personalized prompts, opening Today creates one PromptGenerationJob requesting 60 prompts.
- If generation succeeds with 60 valid unique prompts, Firestore contains 60 new Prompt documents for that couple with source ai_gateway_llm.
- If generation fails, Today still displays the three seeded free prompts for the date and does not require a streak freeze or purchase.
- Generated prompt text over 180 characters or duplicating a prompt used in the last 180 days is not written to prompts.

### Shared answers and history

Both partners can answer the same daily prompt and see whether the other partner has answered, with past prompts available later.

- **Answers complaint:** baseline parity

- **Screens:** Today, Answer Prompt, Streak & History

- **Estimated hours:** 28

**Implementation notes:** For route prompt/{promptId}, load Prompt by id plus DailyAnswer documents where coupleId equals current couple, promptId equals route id, and answeredForDate equals today's ISO local date. Save the current user's answer with document id {coupleId}_{promptId}_{uid}_{date} using set with merge so edits update the same record. Today shows two status indicators per prompt: current user answered and partner answered, based only on document existence, not answer text preview. Streak & History queries DailyAnswer by coupleId ordered by answeredForDate descending, groups by date, and displays prompt text plus the current user's answer; partner answer text is not shown in history unless that partner has saved an answer document for the same prompt/date and the UI is displaying the shared prompt detail.

**Acceptance criteria:**
- Saving an answer creates exactly one DailyAnswer document for that user, prompt, couple, and date.
- Editing the same answer updates answerText and updatedAtMillis without creating a second document.
- The partner answered indicator changes from false to true on the other device through a Firestore snapshot listener.
- Streak & History shows answered prompts in descending date order after at least one answer exists.

### Fair streak tracking and mostly-free repair

Streaks reward consistency but never require paid freezes, including for paying subscribers with long histories.

- **Answers complaint:** Streaks become a monetization lever

- **Screens:** Today, Streak & History, Answer Prompt

- **Estimated hours:** 36

**Implementation notes:** A date is completed when both partners have at least one DailyAnswer for that couple and answeredForDate. Recalculate StreakState after every answer save by scanning completed dates from the latest date backward until a gap. If yesterday is missing and today is answered, show a Repair missed day card for up to 72 hours after the missed local date. Repair creates a special DailyAnswer using a seeded repair prompt for the missed date and increments repairCreditsUsedCount; allow 3 repairs per couple per calendar month for every couple, free or paid. Do not sell additional repair credits and do not show Subscribe from the repair card. Active subscribers receive the same repair rules and must never encounter an extra paywall to keep a streak.

**Acceptance criteria:**
- A couple with 916 completed consecutive dates and active subscription can answer today's free fallback prompt even when personalized prompts are empty.
- No screen contains a button or product to buy streak freezes or repair credits.
- When exactly one yesterday gap exists and the couple has used fewer than 3 repairs in the current month, Streak & History shows Repair missed day.
- After using the third repair in a month, the next missed-day card explains the monthly limit and does not show a purchase or subscribe button.

### Transparent subscription and one-tap cancellation

The app sells the $6.99/month plan honestly and puts an explicit Cancel subscription now button in Settings & Billing.

- **Answers complaint:** Cancellation is deliberately hard

- **Screens:** Subscribe, Settings & Billing

- **Estimated hours:** 30

**Implementation notes:** Configure RevenueCat entitlement 'monthly' mapped to product id duet_daily_monthly at $6.99/month. Subscribe loads offerings from RevenueCat and displays the monthly price string returned by RevenueCat; if the returned price is not $6.99/month, still show the returned store price but keep all copy phrased as monthly subscription. Settings & Billing must be reachable from the Today top bar and must show Cancel subscription now whenever CustomerInfo.entitlements['monthly'].isActive is true. The cancel button launches Intent.ACTION_VIEW to Uri.parse('https://play.google.com/store/account/subscriptions?sku=duet_daily_monthly&package=' + BuildConfig.APPLICATION_ID). There is no intermediate retention screen and no primary button labeled Keep my subscription in the cancellation path. Restore purchases calls Purchases.sharedInstance.restorePurchases and updates SubscriptionEntitlement in Firestore from CustomerInfo.

**Acceptance criteria:**
- A signed-in active subscriber can reach Settings & Billing from Today with one tap on the Settings icon.
- For an active subscriber, Settings & Billing shows a visible button with exact text Cancel subscription now.
- Tapping Cancel subscription now fires an ACTION_VIEW intent whose URL host is play.google.com and whose path contains /store/account/subscriptions.
- No screen in the cancellation path contains a button labeled Keep my subscription.
- The Subscribe screen shows $6.99/month when RevenueCat returns that monthly price.

### Daily reminders and polish

Users can enable a daily reminder notification at a chosen local time without making notifications mandatory.

- **Answers complaint:** baseline parity

- **Screens:** Notification Settings, Today, Settings & Billing

- **Estimated hours:** 12

**Implementation notes:** Use POST_NOTIFICATIONS runtime permission on Android 13+. Notification Settings stores enable flag and hour/minute in users/{uid} plus DataStore cache. Schedule a unique WorkManager PeriodicWorkRequest named daily_prompt_reminder with ExistingPeriodicWorkPolicy.UPDATE; the worker checks FirebaseAuth current uid, local enable flag, and whether today's DailyAnswer exists for the current user before posting a notification. Notification title: 'Your Duet Daily prompt is ready'; body: 'Take a minute together today.' Tapping the notification opens route today. If permission is denied, keep reminders disabled and show permission denied state; the app remains fully usable.

**Acceptance criteria:**
- On Android 13+, enabling reminders before permission is granted shows the permission rationale and system permission request.
- If permission is denied, no WorkManager reminder is scheduled and Notification Settings shows denied state.
- If permission is granted and reminders are enabled, exactly one unique WorkManager work named daily_prompt_reminder exists.
- The worker does not post a notification after the current user has already saved any answer for today's date.

## Store listing

- **Title:** Duet Daily
- **Short description:** Daily couples prompts with fair streaks and honest cancellation.
- **Category:** Lifestyle
- **Keywords:** couples questions, relationship prompts, daily questions, couples journal, streak tracker, partner app, relationship habit
- **Icon prompt:** Create a modern Android app icon for a couples daily conversation app named Duet Daily: rounded square background in warm cream #FFF8F3, centered minimalist purple #7C3AED chat bubble containing two small heart-shaped dots, flat vector style, no text, high contrast, friendly and calm, suitable for Play Store launcher icon.

**Long description:**

Duet Daily helps couples build a small daily habit around thoughtful questions.

Pair with your partner, answer daily prompts, keep a shared streak, and revisit your history without being pushed through dark-pattern billing screens.

Built around the promises users asked for:
• A useful free tier with multiple daily prompt choices
• No paid streak freezes
• No second paywall to keep a streak alive
• Fresh personalized prompts for subscribers
• A visible Cancel subscription now button in settings
• Partner sync across two accounts
• Optional daily reminders

Subscription: Duet Daily offers an optional $6.99/month plan for personalized AI-generated prompt queues. Core daily prompts, answering, history, and fair streak tracking remain usable without subscribing.

Not affiliated with Paired or any other couples app.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://duetdaily.app/privacy (privacy claims verified: no)
- **Data collected:** Email address; Display name; Relationship stage; Interest tags; Partner pairing code and couple identifier; Daily prompt answers; Streak and missed-day repair status; Subscription entitlement status; Notification reminder preference; AI prompt generation inputs derived from relationship stage and interests

## Test plan

### 1. Cancellation is deliberately hard: settings and cancellation must be findable with an honest cancel button, not a hidden keep-subscription path. (instrumented)

1. Launch the app with a fake signed-in Firebase user whose SubscriptionEntitlement.isActive is true and RevenueCat CustomerInfo has active entitlement monthly.
2. Navigate to route today.
3. Tap the top app bar Settings icon.
4. Assert route settings is displayed.
5. Find a button with exact text Cancel subscription now.
6. Tap Cancel subscription now.
7. Intercept the started intent with Espresso Intents.

**Expected:** The intercepted intent action is ACTION_VIEW, the URI starts with https://play.google.com/store/account/subscriptions, includes sku=duet_daily_monthly, includes package equal to BuildConfig.APPLICATION_ID, and no visible node contains text Keep my subscription.

### 2. Streaks become a monetization lever: a paying 916-day streak must not hit a second paywall when prompts run out. (unit)

1. Create a StreakState fixture with currentCount 916, longestCount 916, and lastCompletedDate equal to yesterday.
2. Create an active SubscriptionEntitlement fixture for the user.
3. Create an empty personalized prompt list and a seeded free prompt list with three prompts for today.
4. Run the Today prompt-selection use case for the couple and today's date.
5. Run the streak gating use case after selecting prompts.

**Expected:** The result contains three free fallback prompts, requiresPurchase is false, requiresStreakFreeze is false, and no Subscribe navigation event is emitted.

### 3. "Free" is nearly unusable: non-subscribers must be able to use more than a one-question bait trial. (instrumented)

1. Launch the app with a paired non-subscriber couple and 210 seeded free Prompt documents in the fake Firestore repository.
2. Navigate to route today for a date with no existing answers.
3. Count prompt cards displayed on Today.
4. Open the first prompt card.
5. Enter answer text 'I felt close to you when we cooked together.'
6. Tap Save answer.
7. Navigate to Streak & History.

**Expected:** Today displays exactly three prompt cards, saving succeeds without showing Subscribe, and Streak & History contains the saved answer text.

### 4. Never running out of fresh daily questions: AI generation should refill the queue before exhaustion and fall back safely on failure. (manual)

1. Use a Firebase test project with one paired active-subscriber couple whose profile has relationshipStage='married' and interests=['travel','parenting'].
2. Delete or mark used all but 13 personalized Prompt documents for that couple.
3. Open Today on a physical device signed in as one partner.
4. Observe Firestore promptGenerationJobs for a new job with requestedPromptCount 60.
5. Let the Firebase Function complete successfully once, then repeat with the function forced to return an error.

**Expected:** On success, 60 unique personalized prompts of 180 characters or fewer are written for the couple; on forced failure, Today still shows the three seeded free prompts and no paywall or streak-freeze purchase appears.

### 5. baseline parity: two accounts can pair and see shared answer status in realtime. (instrumented)

1. Start two test app instances using separate Firebase Auth test users A and B against the same fake Firestore repository.
2. On user A, navigate through Onboarding Profile to Pairing and capture the displayed invite code.
3. On user B, navigate to Pairing, enter A's invite code, and tap Join partner.
4. On user A, open Today and then open the first prompt.
5. On user A, save answer text 'A answer'.
6. On user B, observe Today for the same prompt card.

**Expected:** Both users have the same coupleId, both are routed to Today after pairing, and user B's prompt card shows partner answered without requiring a manual refresh.

### 6. baseline parity: daily reminder worker should not notify after the user already answered today. (unit)

1. Create a fake DataStore preference with reminders enabled at 20:00.
2. Create a fake repository response showing the current user has one DailyAnswer with answeredForDate equal to today's ISO date.
3. Run DailyPromptReminderWorker.doWork with a fake notification manager.
4. Record notification manager post calls.

**Expected:** Worker returns Result.success and the fake notification manager records zero posted notifications.

## Build instructions

```sh
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
keytool -genkeypair -v -keystore release-upload.jks -storepass changeit123 -keypass changeit123 -alias upload -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Duet Daily,O=Solo Builder,L=Internet,S=NA,C=US" || true
RELEASE_STORE_FILE=$PWD/release-upload.jks RELEASE_STORE_PASSWORD=changeit123 RELEASE_KEY_ALIAS=upload RELEASE_KEY_PASSWORD=changeit123 ./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
