# Closer iOS App — Architecture Audit

> Generated from the Android (Kotlin) codebase at `app/src/main/java/app/closer/`,
> Cloud Functions at `functions/src/`, and secondary Express server at `server/src/`.
>
> Intended audience: SwiftUI developer building the iOS port without reading Kotlin.

---

## Table of Contents

1. [iOS-Specific Adaptations](#1-ios-specific-adaptations)
2. [Auth & Onboarding Flow](#2-auth--onboarding-flow)
3. [Screen Map (Android → iOS)](#3-screen-map-android--ios)
4. [Navigation Structure](#4-navigation-structure)
5. [Firestore Collections & Documents](#5-firestore-collections--documents)
6. [Domain Models (Swift Equivalents)](#6-domain-models-swift-equivalents)
7. [Cloud Functions](#7-cloud-functions)
8. [Secondary Express Server](#8-secondary-express-server)
9. [RevenueCat / Entitlements](#9-revenuecat--entitlements)
10. [E2EE / Crypto Layer](#10-e2ee--crypto-layer)
11. [UI Design System](#11-ui-design-system)
12. [DI Pattern (Hosting)](#12-di-pattern)
13. [Project File Layout for /iphone](#13-project-file-layout)

---

## 1. iOS-Specific Adaptations

### Android APIs with no direct iOS equivalent

| Android API | What it's used for | iOS Replacement |
|---|---|---|
| `Hilt` (Dagger) | Dependency injection throughout app | Swift manual DI or factory protocol (no Swinject needed; the app is small enough for manual dependency graph) |
| `Navigation Compose` | NavHost, composable route system | `SwiftUI.NavigationStack` + `.navigationDestination` |
| `Material 3` | Theme, components, surfaces | SwiftUI native + custom color/assets |
| `Compose Canvas` | SpinWheel rendering (`Canvas`, `drawArc`, `rotate`) | `SwiftUI Canvas` or `CoreGraphics` rendering |
| `Room` (SQLite) | Local question bank, preloaded DB asset | Bundled JSON or CoreData for local question data |
| `EncryptedSharedPreferences` | Secure key-value crypto storage | `Keychain Services` (for secret/keys) + `UserDefaults` (for non-secret prefs) — managed via `Security.framework` / `CryptoKit` |
| `Tink` (Google crypto) | AES-256-GCM, ECIES, keyset serialization | `CryptoKit` (AES-GCM, P256/Curve25519) or `SwiftCrypto` / `BoringSSL` wrapper — KEY REQUIREMENT: must produce byte-compatible ciphertexts |
| `Google Play Integrity API` | Device integrity check | Apple `DeviceCheck` or `App Attest` — or skip for MVP |
| `Google Sign-In` | Auth | `FirebaseAuth` with `GoogleSignIn-iOS` pod |
| `FCM` via `FirebaseMessaging` | Push notifications | Same — Firebase iOS SDK handles FCM |
| `RevenueCat Android SDK` | Subscriptions | `purchases-ios` (RevenueCat Swift SDK) — same backend |
| `Argon2id` (Bouncy Castle) | KDF for recovery key | `CryptoKit` doesn't include Argon2 — need `SwiftArgon2` wrapper or `IDZSwiftCommonCrypto` — IMPORTANT: must produce identical bytes to verify Android-generated recovery phrases |
| `ActivityResultLauncher` | Google Sign-in activity result | `ASWebAuthenticationSession` / `CredentialManager` |

### iOS-Specific Additions

| Component | Notes |
|---|---|
| `Info.plist` | Push notification entitlement, background modes, URL schemes (Google Sign-In, RevenueCat) |
| `.entitlements` file | Push, Keychain sharing (for RevenueCat), App Groups (optional for widget) |
| `GoogleService-Info.plist` | Firebase config (generate from Firebase Console) |
| `RevenueCat.plist` / API key | Set via code, same as Android `BuildConfig.RC_API_KEY` |
| `Podfile` or SPM | Firebase, RevenueCat, Google Sign-In via SPM |
| `KeychainHelper` | Wrapper for `SecItemAdd`/`SecItemCopyMatching` — replaces `EncryptedSharedPreferences` |
| `Notification Service Extension` | For rich FCM display (optional) |

---

## 2. Auth & Onboarding Flow

### Flow Diagram (Logical)

```
App Launch
  │
  ├─ AuthState.Unauthenticated ─→ OnboardingScreen
  │                                  │
  │                                  ├─ Sign Up (email + password)
  │                                  ├─ Login (email + password)
  │                                  ├─ Forgot Password
  │                                  └─ Try Anonymously (optional — used
  │                                      for test/dev only, not first run)
  │
  ├─ AuthState.Authenticated(isAnonymous=true) ─→ CreateProfileScreen
  │                                                  │
  │                                                  └─ User doc created in Firestore
  │
  └─ AuthState.Authenticated(isAnonymous=false) ─→ userDoc.coupleId
                                                       │
                                                       ├─ null/empty ─→ PairPromptScreen
                                                       │                    │
                                                       │                    ├─ CreateInviteScreen (generate code)
                                                       │                    ├─ AcceptInviteScreen (enter code)
                                                       │                    └─ EmailInviteScreen (share by email)
                                                       │
                                                       └─ exists ─→ HomeScreen (main app)
```

### Client-Side Rate Limiting (AuthRateLimiter)

Android uses `AuthRateLimiter` — a singleton object with per-flow counters:

- **Flows:** `LOGIN`, `SIGN_UP`, `PASSWORD_RESET`, `ANONYMOUS`
- **Soft limit:** 3 failures → exponential backoff (1s × 2^(failures-3), capped at 8s)
- **Hard limit:** 5 failures → 30-second lockout
- Resets on success.

**iOS:** Implement identical logic in a Swift class. No dependency needed — pure math + `Date()`. Pattern:

```swift
final class AuthRateLimiter {
    enum Flow { case login, signUp, passwordReset, anonymous }
    // ... same constants and logic
}
```

### Auth Data Sources

**FirebaseAuthDataSource** wraps Firebase Auth SDK. Key methods the iOS port must replicate:

| Method | Firebase Auth API (iOS) |
|---|---|
| `signInAnonymously()` | `Auth.auth().signInAnonymously()` |
| `signInWithEmail(email, password)` | `Auth.auth().signInWithEmail(email, password)` |
| `signUpWithEmail(email, password)` | `Auth.auth().createUserWithEmail(email, password)` |
| `sendPasswordResetEmail(email)` | `Auth.auth().sendPasswordResetEmail(email)` |
| `signInWithGoogle(idToken)` | `FIRGoogleAuthProvider.credential(withIDToken:)` inside Google Sign-In delegate |
| `signOut()` | `try Auth.auth().signOut()` |
| `reauthenticateWithEmail(email, password)` | `user.reauthenticate(with:)` |
| `deleteAccount()` | `user.delete()` |
| `authState: Flow<AuthState>` | `Auth.auth().addStateDidChangeListener {}` → combine into `AsyncStream` or `@Published` |

**AuthState enum:**

```swift
enum AuthState {
    case loading
    case authenticated(userId: String, isAnonymous: Bool)
    case unauthenticated
}
```

### Google Sign-In Flow (iOS)

1. Configure `GoogleService-Info.plist` in Xcode target (includes reversed client ID URL scheme)
2. Use `GIDSignIn.sharedInstance.signIn(withPresenting:)` to get `GIDGoogleUser`
3. Extract `idToken` from `user.idToken.tokenString`
4. Create Firebase credential: `GoogleAuthProvider.credential(withIDToken: idToken, accessToken: accessToken)`
5. Call `Auth.auth().signIn(with: credential)`

---

## 3. Screen Map (Android → iOS)

### Grouped by Navigation Tab/Flow

#### Onboarding & Auth (no bottom nav)

| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 1 | `onboarding` | `OnboardingScreen` | Welcome carousel, "Get Started" | `OnboardingView` |
| 2 | `login` | `LoginScreen` | Email/password login form | `LoginView` |
| 3 | `sign_up` | `SignUpScreen` | Email/password registration | `SignUpView` |
| 4 | `forgot_password` | `ForgotPasswordScreen` | Reset email form | `ForgotPasswordView` |
| 5 | `create_profile` | `CreateProfileScreen` | User display name, avatar, sex | `CreateProfileView` |

#### Pairing

| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 6 | `pair_prompt` | `PairPromptScreen` | "Create invite" or "Accept invite" | `PairPromptView` |
| 7 | `create_invite` | `CreateInviteScreen` | 6-char invite code display, share sheet | `CreateInviteView` |
| 8 | `accept_invite` | `AcceptInviteScreen` | Enter code text field | `AcceptInviteView` |
| 9 | `email_invite` | `EmailInviteScreen` | Email entry to send invite | `EmailInviteView` |
| 10 | `invite_confirm/{code}` | `InviteConfirmScreen` | Confirmation before pairing | `InviteConfirmView` |
| 11 | `recovery` | `RecoveryScreen` | Unlock answers with recovery phrase | `RecoveryView` |
| 12 | `encryption_upgrade` | `EncryptionUpgradeScreen` | E2EE upgrade banner/flow | `EncryptionUpgradeView` |

#### Home (tab 1 — bottom nav)

| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 13 | `home` | `HomeScreen` | Main dashboard — streak, question, partner status | `HomeView` |
| 14 | `partner_home` | `PartnerHomeScreen` | Partner's home/activity view | `PartnerHomeView` |

#### Daily Question (tab 2 — bottom nav)

| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 15 | `daily_question` | `DailyQuestionScreen` | Today's question (text, multi-choice, scale) | `DailyQuestionView` |
| 16 | `answer_reveal/{questionId}` | `AnswerRevealScreen` | Reveal partner's sealed answer | `AnswerRevealView` |
| 17 | `answer_history` | `AnswerHistoryScreen` | Past answer history (calendar/list) | `AnswerHistoryView` |
| 18 | `question_packs` | `QuestionPackLibraryScreen` | Browse curated question packs | `QuestionPackLibraryView` |
| 19 | `question_category/{categoryId}` | `QuestionCategoryScreen` | Questions in a pack | `QuestionCategoryView` |
| 20 | `question_composer` | `QuestionComposerScreen` | Create + send custom question | `QuestionComposerView` |
| 21 | `question_thread/{coupleId}/{questionId}` | `QuestionThreadScreen` | Ask-answer-reveal thread for custom questions | `QuestionThreadView` |

#### Play (tab 3 — bottom nav)

| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 22 | `play` | `PlayHubScreen` | Game selection hub (cards) | `PlayHubView` |
| 23 | `this_or_that` | `ThisOrThatScreen` | Binary choice game | `ThisOrThatView` |
| 24 | `how_well` | `HowWellScreen` | "How well do you know me?" quiz | `HowWellView` |
| 25 | `desire_sync` | `DesireSyncScreen` | Intimacy preference matching | `DesireSyncView` |
| 26 | `connection_challenges` | `ConnectionChallengesScreen` | Multi-day challenge programs | `ConnectionChallengesView` |
| 27 | `memory_lane` | `MemoryLaneScreen` | Time capsule viewer | `MemoryLaneView` |
| 28 | `waiting_for_partner` | `WaitingForPartnerScreen` | "Waiting for partner to finish" interstitial | `WaitingForPartnerView` |
| 29 | `game_history` | (inline in PlayHub) | Past game results | `GameHistoryView` |
| 30 | `this_or_that_replay/{sessionId}` | `ThisOrThatReplayScreen` | Replay past TOT result | `ThisOrThatReplayView` |
| 31 | `desire_sync_replay/{sessionId}` | `DSReplayScreen` | Replay past DS result | `DesireSyncReplayView` |
| 32 | `how_well_replay/{sessionId}` | `HowWellReplayScreen` | Replay past HW result | `HowWellReplayView` |

#### Spin Wheel (sub-flow of Play)

| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 33 | `category_picker` | `CategoryPickerScreen` | Pick wheel category | `CategoryPickerView` |
| 34 | `spin_wheel/{categoryId}` | `SpinWheelScreen` | Canvas-animated spin wheel | `SpinWheelView` |
| 35 | `wheel_session/{sessionId}` | `WheelSessionScreen` | During-session steps/prompts | `WheelSessionView` |
| 36 | `wheel_complete/{sessionId}` | `WheelCompleteScreen` | Session results | `WheelCompleteView` |
| 37 | `wheel_history` | `WheelHistoryScreen` | Past wheel sessions | `WheelHistoryView` |

#### Date Ideas (sub-flow)

| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 38 | `date_match` | `DateMatchScreen` | Tinder-style swipe on date ideas | `DateMatchView` |
| 39 | `date_matches` | `DateMatchesScreen` | Revealed matches list | `DateMatchesView` |
| 40 | `date_builder` | `DateBuilderScreen` | Date plan builder/form | `DateBuilderView` |
| 41 | `bucket_list` | `BucketListScreen` | Shared bucket list | `BucketListView` |

#### Settings (tab 5 — bottom nav)

| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 42 | `settings` | `SettingsScreen` | Main settings list | `SettingsView` |
| 43 | `account` | `AccountScreen` | Email, password, delete account | `AccountView` |
| 44 | `notifications` | `NotificationSettingsScreen` | Notification toggles | `NotificationSettingsView` |
| 45 | `privacy` | `PrivacyScreen` | Privacy policy, data export, E2EE info | `PrivacyView` |
| 46 | `subscription` | `SubscriptionScreen` | Plan details, manage subscription | `SubscriptionView` |
| 47 | `relationship_settings` | `RelationshipSettingsScreen` | Partner info, unlink, leave couple | `RelationshipSettingsView` |
| 48 | `delete_account` | `DeleteAccountScreen` | Confirm + delete | `DeleteAccountView` |

#### Paywall (presented modally)

| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 49 | `paywall` | `PaywallScreen` | Subscription offering (premium features) | `PaywallView` |

---

## 4. Navigation Structure

### Android Bottom Nav Tabs

5 top-level tabs:

| Index | Route | Label | Icon |
|---|---|---|---|
| 0 | `home` | Home | `house.fill` |
| 1 | `daily_question` | Today | `heart.fill` |
| 2 | `play` | Play | `play.fill` |
| 3 | `question_packs` | Packs | `star.fill` |
| 4 | `settings` | Settings | `gearshape.fill` |

### iOS NavigationStack Pattern

```swift
struct AppNavigation: View {
    @State private var selectedTab: Tab = .home

    var body: some View {
        TabView(selection: $selectedTab) {
            HomeFlow()
                .tabItem { ... }
                .tag(Tab.home)

            DailyQuestionFlow()
                .tabItem { ... }
                .tag(Tab.dailyQuestion)

            PlayFlow()
                .tabItem { ... }
                .tag(Tab.play)

            QuestionPacksFlow()
                .tabItem { ... }
                .tag(Tab.questionPacks)

            SettingsFlow()
                .tabItem { ... }
                .tag(Tab.settings)
        }
    }
}
```

Each flow wraps a `NavigationStack` with its own path. Drill-in routes use `.navigationDestination(for:)`.

### Route Parameter Patterns

| Route | Parameter | Type | Notes |
|---|---|---|---|
| `question_category/{categoryId}` | `categoryId` | String | UUID |
| `spin_wheel/{categoryId}` | `categoryId` | String | UUID |
| `wheel_session/{sessionId}` | `sessionId` | String | UUID |
| `wheel_complete/{sessionId}` | `sessionId` | String | UUID |
| `answer_reveal/{questionId}` | `questionId` | String | UUID |
| `invite_confirm/{inviteCode}` | `inviteCode` | String | 6-char alphanumeric |
| `question_thread/{coupleId}/{questionId}?prevId=...&nextId=...` | `coupleId`, `questionId` | String | Optional prevId/nextId |
| `this_or_that_replay/{sessionId}` | `sessionId` | String | UUID |
| `desire_sync_replay/{sessionId}` | `sessionId` | String | UUID |
| `how_well_replay/{sessionId}` | `sessionId` | String | UUID |

---

## 5. Firestore Collections & Documents

### Collection Map

```
/users/{uid}
    ├── displayName: String
    ├── email: String
    ├── photoUrl: String
    ├── sex: String
    ├── partnerId: String | null
    ├── coupleId: String | null
    ├── plan: String ("free" | "premium")
    ├── createdAt: Timestamp
    ├── lastActiveAt: Timestamp
    ├── fcmToken: String (legacy, single token)
    ├── fcmTokens/{tokenId}              (subcollection — multi-device)
    ├── entitlements/
    │   └── premium                      (subcollection doc)
    │       ├── premium: boolean
    │       ├── expiresAt: Timestamp | null
    │       └── updatedAt: Timestamp
    ├── devices/
    │   └── primary                      (subcollection doc — E2EE device key)
    │       ├── publicKey: String ("pub:v1:{base64}")
    │       └── updatedAt: Timestamp
    └── notification_queue/              (for in-app notification center)
        └── {notificationId}

/invites/{code}                          (doc ID = 6-char code)
    ├── inviterUserId: String
    ├── inviteeEmail: String | null
    ├── coupleId: String | null
    ├── status: String ("pending" | "accepted" | "expired")
    ├── createdAt: Timestamp
    ├── expiresAt: Timestamp
    ├── acceptedAt: Timestamp | null
    ├── acceptedByUserId: String | null
    ├── wrappedCoupleKey: String | null  (E2EE)
    ├── kdfSalt: String | null           (E2EE)
    └── kdfParams: String | null         (E2EE)

/couples/{coupleId}
    ├── userIds: [String, String]
    ├── inviteCode: String
    ├── createdAt: Timestamp
    ├── currentQuestionId: String | null
    ├── streakCount: Int
    ├── lastAnsweredAt: Timestamp | null
    ├── activePackId: String | null
    ├── encryptionVersion: Int (0=plaintext, 1=migrating, 2=strict E2EE)
    ├── wrappedCoupleKey: String | null  (E2EE)
    ├── kdfSalt: String | null           (E2EE)
    ├── kdfParams: String | null         (E2EE)
    ├── encryptionMigrationUsers: Map<String, Bool>
    ├── daily_question/{date}            (subcollection — date = YYYY-MM-DD)
    │   ├── questionId: String
    │   ├── date: String
    │   ├── assignedAt: Timestamp
    │   ├── expiresAt: Timestamp
    │   ├── answers/{userId}             (subcollection)
    │   │   ├── sealedAnswer: String | null  ("sealed:v1:..." ciphertext)
    │   │   ├── commitment: String | null    ("sha256:..." hash)
    │   │   ├── questionType: String
    │   │   ├── submittedAt: Timestamp
    │   │   ├── releaseKeys/{userId}     (subcollection — keybox at reveal)
    │   │   │   └── keybox: String ("keybox:v1:..." wrapped key)
    │   │   └── ...
    │   └── ...
    ├── question_threads/{threadId}      (subcollection)
    │   ├── questionId: String
    │   ├── senderId: String
    │   ├── status: String
    │   ├── answers/                     (subcollection)
    │   ├── messages/                    (subcollection)
    │   ├── reactions/                   (subcollection)
    │   ├── releaseKeys/                 (subcollection)
    │   └── ...
    ├── date_swipes/{dateIdeaId}         (subcollection)
    │   └── actions: Map<uid, {action: String, swipedAt: Number}>
    ├── date_matches/{dateIdeaId}        (subcollection — server-write-only)
    ├── date_plan_preferences/{userId}   (subcollection)
    ├── date_plans/{planId}              (subcollection)
    ├── bucket_list/{itemId}             (subcollection)
    ├── challenges/{challengeId}         (subcollection)
    ├── capsules/{capsuleId}             (subcollection)
    │   ├── status: String ("sealed" | "unlocked")
    │   ├── unlockAt: Timestamp
    │   └── ...
    ├── this_or_that/{sessionId}         (subcollection)
    ├── wheel/{sessionId}                (subcollection)
    ├── desire_sync/{sessionId}          (subcollection)
    ├── how_well/{sessionId}             (subcollection)
    ├── sessions/{sessionId}             (subcollection — generic game sessions)
    ├── gentle_reminders/{date}          (subcollection — rate-limit lock)
    └── ... (future subcollections)
```

### Security Rules Patterns (from code context)

- `daily_question` docs: server-write-only (`allow write: if false` in rules)
- `date_matches`: server-write-only (trigger-based)
- `date_swipes`: client-write, each user can only write own `actions[uid]`
- `answers`: client-write own answer, read when both have submitted or revealed
- `invites`: server-read via callable only (6-char code = enumerable, so clients don't read invites directly)
- `users`: client-read own, client-write own fields except `coupleId`
- `couples`: member-read, member-write non-critical fields
- `entitlements/{userId}/premium`: server-write only (RevenueCat webhook)

### Stale Code / Deprecated Fields

- `fcmToken` (single string on user doc) is legacy — iOS should use `fcmTokens/{tokenId}` subcollection
- `answer.answerText` on the `Answer` model is likely legacy — actual daily question answers use `sealedAnswer` + `commitment`

---

## 6. Domain Models (Swift Equivalents)

### Firestore-Mapped Models

#### User

```swift
struct User: Codable, Identifiable {
    let id: String
    var email: String
    var displayName: String
    var photoUrl: String
    var sex: String
    var partnerId: String?
    var coupleId: String?
    var plan: String  // "free" | "premium"
    var createdAt: Timestamp
    var lastActiveAt: Timestamp
}
```

#### Couple

```swift
struct Couple: Codable, Identifiable {
    let id: String
    var userIds: [String]
    var inviteCode: String
    var createdAt: Timestamp
    var currentQuestionId: String?
    var streakCount: Int
    var lastAnsweredAt: Timestamp?
    var activePackId: String?
    // E2EE
    var encryptionVersion: Int               // 0=plaintext, 1=migrating, 2=strict
    var wrappedCoupleKey: String?
    var kdfSalt: String?
    var kdfParams: String?
    var encryptionMigrationUsers: [String: Bool]
}
```

#### Invite

```swift
struct Invite: Codable, Identifiable {
    let id: String  // = document ID
    var code: String
    var inviterUserId: String
    var inviteeEmail: String?
    var coupleId: String?
    var status: String  // "pending" | "accepted" | "expired"
    var createdAt: Timestamp
    var expiresAt: Timestamp
    var acceptedAt: Timestamp?
    var acceptedByUserId: String?
    // E2EE
    var wrappedCoupleKey: String?
    var kdfSalt: String?
    var kdfParams: String?
}
```

#### Entitlement

```swift
struct Entitlement: Codable, Identifiable {
    let id: String
    var userId: String
    var source: String
    var productId: String
    var isActive: Bool
    var expiresAt: Timestamp?
    var updatedAt: Timestamp
}
```

#### Answer (Daily Question)

```swift
struct DailyAnswer: Codable, Identifiable {
    let id: String            // = userId
    var sealedAnswer: String? // "sealed:v1:{base64}"
    var commitment: String?   // "sha256:{urlsafe-base64}"
    var questionType: String  // "text" | "multiple_choice" | "scale"
    var submittedAt: Timestamp
}
```

#### Streak

```swift
enum Streak {
    struct Couple: StreakType { ... }
    struct Personal: StreakType { ... }
    struct WeeklyRhythm: StreakType { ... }
}
protocol StreakType {
    var count: Int { get }
    var lastActiveDate: Date? { get }
    var includesToday: Bool { get }
}

struct StreakResult {
    let coupleStreak: Streak.Couple
    let personalStreak: Streak.Personal
    let weeklyRhythm: Streak.WeeklyRhythm
    let milestoneCopy: String?
    let canRepair: Bool
    let repairDueDate: Date?
}
```

### Other Models (read model files for full field lists)

| Model | File | Notes |
|---|---|---|
| `QuestionCategory` | `QuestionCategory.kt` | id, name, description, icon, color, packId |
| `QuestionPack` | `QuestionPack.kt` | id, name, description, categories[] |
| `Question` | `Question.kt` | id, text, type, options[], scaleMin/Max, categoryId |
| `QuestionThread` | `QuestionThread.kt` | coupls question thread |
| `QuestionMessage` | `QuestionMessage.kt` | messages within a thread |
| `QuestionReaction` | `QuestionReaction.kt` | reactions on answers |
| `QuestionSession` | `QuestionSession.kt` | session state |
| `AuthState` | `AuthState.kt` | sealed class: Loading, Authenticated, Unauthenticated |
| `GoogleSignInResult` | `GoogleSignInResult.kt` | uid, displayName, photoUrl, email, isAnonymous |
| `BucketListItem` | `BucketListItem.kt` | id, title, description, createdBy, completed, completedAt |
| `DateIdea` | `DateIdea.kt` | id, title, description, category, cost, duration, location |
| `DateMatch` | `DateMatch.kt` | derived from DateIdea when both match |
| `DateSwipe` | `DateSwipe.kt` | userId, action ("love" | "pass"), swipedAt |
| `DatePlan` | `DatePlan.kt` | planned date details |
| `DatePlanPreference` | `DatePlanPreference.kt` | preferences for date builder |
| `DateMatchSuggestion` | `DateMatchSuggestion.kt` | suggested matches |
| `DatePlanSuggestion` | `DatePlanSuggestion.kt` | suggested plans |
| `DateSuggestion` | `DateSuggestion.kt` | general suggestions |
| `ConnectionChallenge` | `ConnectionChallenge.kt` | id, title, description, durationDays, tasks[] |
| `ChallengeState` | `ChallengeState.kt` | challenge progress per couple |
| `GameType` | `GameType.kt` | constants: "wheel", "this_or_that", "how_well", "desire_sync", "connection_challenges" |
| `LocalAnswer` | `LocalAnswer.kt` | local-only cached answers |
| `MemoryCapsule` | `MemoryCapsule.kt` | id, title, content, status, unlockAt |
| `TimeCapsule` | `TimeCapsule.kt` | similar to MemoryCapsule |
| `SessionLength` | `SessionLength.kt` | enum: short, medium, long |
| `QuestionSessionStatus` | `QuestionSessionStatus.kt` | session progress state |
| `WeeklyRecap` | `WeeklyRecap.kt` | weekly summary stats |
| `QuestionAnswer` | `QuestionAnswer.kt` | answer model for custom questions |
| `InviteStatus` | `InviteStatus.kt` | invite lifecycle state |

---

## 7. Cloud Functions

### Callable Functions (Client Invokes via `functions.httpsCallable`)

| Function | File | Input | Output | Auth Required |
|---|---|---|---|---|
| `acceptInviteCallable` | `couples/acceptInviteCallable.ts` | `{ code: string, recoveryPhrase?: string }` | `{ coupleId: string }` | ✅ |
| `leaveCoupleCallable` | `couples/leaveCoupleCallable.ts` | `_data: any` | `{ success: bool }` | ✅ |
| `syncEntitlement` | `billing/syncEntitlement.ts` | `{}` | `EntitlementState { premium, expiresAt, updatedAt }` | ✅ |
| `sendDailyQuestionReminder` | `notifications/reminders.ts` | `{ type, userId, title, body }` | `{ queued: bool }` | ✅ |
| `sendPartnerAnsweredNotification` | `notifications/reminders.ts` | `{ type, userId, title, body }` | `{ queued: bool }` | ✅ |
| `sendGentleReminderCallable` | `notifications/sendGentleReminderCallable.ts` | `{}` | `{ success: bool }` | ✅ |
| `assignDailyQuestionCallable` | `questions/assignDailyQuestion.ts` | (manual trigger) | `{ assigned: Int }` | Admin |
| `checkDeviceIntegrity` | `security/checkDeviceIntegrity.ts` | `{ token: string }` | `{ passed: bool, verdicts: string[] }` | ✅ (Android only — skip for iOS MVP) |

### Firebase Auth / Firestore Triggers

| Function | File | Trigger | Purpose |
|---|---|---|---|
| `onUserDelete` | `users/onUserDelete.ts` | `auth.user().onDelete` | Cascade: unlink couple, delete user doc/subcollections, clean up Storage |
| `onCoupleLeave` | `couples/onCoupleLeave.ts` | `users/{uid}.onUpdate` (coupleId → null) | FCM to remaining partner |
| `onAnswerWritten` | `questions/onAnswerWritten.ts` | `couples/{c}/daily_question/{d}/answers/{uid}.onCreate` | FCM to partner "your partner answered" |
| `createDateMatchOnMutualLove` | `dates/createDateMatch.ts` | `couples/{c}/date_swipes/{ideaId}.onWrite` | If both LOVE, create match doc |
| `onGameSessionUpdate` | `games/onGameSessionUpdate.ts` | `couples/{c}/sessions/{s}.onWrite` | FCM for session start/complete |

### Scheduled Functions

| Function | File | Schedule | Purpose |
|---|---|---|---|
| `assignDailyQuestion` | `questions/assignDailyQuestion.ts` | Daily at 6 PM CST (`0 23 * * *` America/Chicago) | Assign today's question to all couples |
| `unlockDueMemoryCapsules` | `notifications/gameRetention.ts` | Every 1 hour | Unlock sealed capsules past their unlockAt |
| `sendChallengeDayReminders` | `notifications/gameRetention.ts` | (via retention module) | Reminders for multi-day challenge progress |

### Webhook HTTP Functions

| Function | File | Method | Purpose |
|---|---|---|---|
| `revenueCatWebhook` | `billing/revenueCatWebhook.ts` | POST (Ed25519 signed) | RevenueCat server events → Firestore entitlements |
| `health` | `index.ts` | GET | `{ status: "ok" }` |

### iOS Firebase Functions Integration Pattern

```swift
import FirebaseFunctions

let functions = Functions.functions()

// Call a callable function
func acceptInvite(code: String, recoveryPhrase: String?) async throws -> String {
    let data: [String: Any] = ["code": code]
    let result = try await functions.httpsCallable("acceptInviteCallable").call(data)
    let coupleId = (result.data as? [String: Any])?["coupleId"] as? String ?? ""
    return coupleId
}
```

---

## 8. Secondary Express Server

The `server/` directory contains a standalone Express server, **NOT** Firebase Cloud Functions. It runs separately.

| File | Purpose |
|---|---|
| `src/index.ts` | Express app init, helmet, morgan, raw body capture |
| `src/config/env.ts` | ENV validation |
| `src/config/firebase.ts` | Firebase Admin SDK init (for Firestore + FCM access) |
| `src/routes/health.ts` | `GET /health` |
| `src/routes/webhooks.ts` | Webhook endpoint (rate-limited) |
| `src/middleware/rateLimiter.ts` | Rate limiting middleware |
| `src/services/entitlement.ts` | Server-side entitlement logic (redundant with Cloud Functions?) |
| `src/services/fcm.ts` | FCM send helpers |
| `src/listeners/answerListener.ts` | Firestore listener for answer changes |
| `src/types/index.ts` | Shared TypeScript types |

**iOS Impact:** The Express server is not client-facing — it handles internal webhooks and secondary Firestore listeners. iOS app only needs to call Firebase through the Firebase iOS SDK and Cloud Functions (Section 7). The Express server runs independently.

**Note:** The `/server` directory contains code that overlaps with Firebase Cloud Functions. The iOS app should treat Cloud Functions (Section 7) as the authoritative backend contract.

---

## 9. RevenueCat / Entitlements

### Architecture

```
RevenueCat API
    │
    ▼
revenueCatWebhook (Cloud Function) ──→ Firestore users/{uid}/entitlements/premium
    │                                      { premium: bool, expiresAt, updatedAt }
    ▼
syncEntitlement (callable function) ←── Client calls after purchase/restore
    │
    ▼
Client:
    Purchases SDK (RevenueCat)
        │
        ├─ getOfferings()             → show paywall plans
        ├─ purchase(pkg)              → buy subscription
        ├─ restorePurchases()         → restore
        └─ updatedCustomerInfoListener → flows through EntitlementChecker
```

### EntitlementChecker Protocol (Android interface)

```swift
protocol EntitlementChecker {
    /// Reactive stream emitting premium status
    var isPremium: AsyncStream<Bool> { get }

    /// One-shot check
    func hasPremium() async -> Bool

    /// Notify of RevenueCat CustomerInfo update
    func onCustomerInfoUpdated(_ info: CustomerInfo)
}
```

**iOS Implementation Strategy:**
1. Integrate RevenueCat via SPM (`purchases-ios`)
2. Configure with API key (same `RC_API_KEY` as Android BuildConfig)
3. Use `Purchases.shared.customerInfoStream` (AsyncSequence) instead of listener
4. After purchase, call `syncEntitlement` Cloud Function
5. Also observe Firestore `users/{uid}/entitlements/premium` as server-side source of truth
6. `EntitlementChecker` merges RevenueCat `CustomerInfo.isEntitledTo("closer_premium")` with Firestore document

### What's Premium vs Free (from UI-PLAN.md + code context)

| Feature | Free | Premium |
|---|---|---|
| Daily Questions | ✅ | ✅ |
| Answer Reveal | ✅ | ✅ |
| Question Packs (browse) | ✅ | ✅ |
| Custom Questions | 🚫 | ✅ |
| Spin Wheel | 🚫 | ✅ |
| This or That | 🚫 | ✅ |
| How Well Do You Know Me | 🚫 | ✅ |
| Desire Sync | 🚫 | ✅ |
| Connection Challenges | 🚫 | ✅ |
| Memory Lane / Capsules | 🚫 | ✅ |
| Date Matching (swipe) | ✅ (limited) | ✅ (unlimited) |
| Date Builder | 🚫 | ✅ |
| Bucket List | ✅ (limited) | ✅ (unlimited) |
| Paid plans (from PAYWALL): | | Monthly / Annual |

**Paywall gating:** The `PaywallScreen` is presented modally when the user taps a premium feature. The `BillingRepository` protocol exposes getOfferings/purchase/restore.

### BillingRepository Protocol

```swift
protocol BillingRepository {
    func getOfferings() async -> Result<Offerings, Error>
    func purchase(package: Package) async -> Result<PurchaseResult, Error>
    func restorePurchases() async -> Result<CustomerInfo, Error>
    var customerInfo: AsyncStream<Result<CustomerInfo, Error>> { get }
}
```

### RevenueCat Entitlement ID

The entitlement ID used in the webhook and throughout the codebase is:

> **`closer_premium`**

Product IDs from the codebase (in entitlement logic tests):
- `closer_premium_monthly`
- `closer_premium_annual`

---

## 10. E2EE / Crypto Layer

### Architecture Overview

The Android app implements **optional E2EE** for daily question answers. When enabled, answers are:

1. **Sealed at submit time** — encrypted with a one-time AES-256-GCM key
2. **Committed** — SHA-256 hash of plaintext written to Firestore
3. **Revealed** — one-time key released to partner via ECIES key exchange

### Encryption Tiers

| `encryptionVersion` | Meaning |
|---|---|
| 0 | Legacy plaintext — no encryption |
| 1 | Migration in progress — mixed plaintext + encrypted |
| 2 | Strict E2EE — all answer paths encrypted |

### Key Components

| Android Class | iOS Equivalent | Purpose |
|---|---|---|
| `SealedAnswerEncryptor` | `SealedAnswerEncryptor` | AES-256-GCM per-answer encrypt/decrypt. Wire: `"sealed:v1:{base64}"`, AAD = `"{coupleId}\|{questionId}\|{userId}"` |
| `AnswerCommitment` | `AnswerCommitment` | SHA-256 commitment. Input: `"v1\|{coupleId}\|{questionId}\|{userId}\|{canonicalJson}"`, output: `"sha256:{urlsafe-base64}"` |
| `UserKeyManager` | `UserKeyManager` | Per-user ECIES keypair (Curve25519 or P-256), stored in Keychain. Public key published to `users/{uid}/devices/primary` |
| `CoupleKeyStore` | `CoupleKeyStore` | Persists couple keypairs, uses Keychain |
| `CoupleEncryptionManager` | `CoupleEncryptionManager` | Orchestrates setup, migration, recovery phrase |
| `RecoveryKeyManager` | `RecoveryKeyManager` | Argon2id KDF + phrase generation (BIP39-style wordlist). m=46MiB, t=3, p=1. **Must produce identical output to verify Android-generated phrases.** |
| `PendingAnswerKeyStore` | `PendingAnswerKeyStore` | Stores one-time answer keys pre-reveal (Keychain) |
| `ReleaseKeyEncryptor` | `ReleaseKeyEncryptor` | Wraps one-time key to partner's public key via ECIES. Wire: `"keybox:v1:{urlsafe-base64}"` |
| `SealedRevealManager` | `SealedRevealManager` | Two-sided reveal: release own key → decrypt partner's answer |
| `FieldEncryptor` | `FieldEncryptor` | Per-field encrypt for Firestore fields. Wire: `"enc:v1:{base64}"`, AAD = coupleId |

### Answer Flow (E2EE)

```
1. Compose answer (text/choice/scale)
2. Compute commitment SHA-256 → Firestore
3. Generate one-time AES-256-GCM key → store locally (PendingAnswerKeyStore)
4. Encrypt payload → Firestore sealedAnswer field
5. [Partner answers too]
6. Release: wrap one-time key to partner's public key (ECIES) → Firestore releaseKeys/{partnerId}
7. Recover: read partner's keybox, unwrap with own private key → decrypt sealedAnswer
```

### iOS Crypto Strategy

**Critical constraint:** iOS crypto MUST produce byte-for-byte compatible output with Android's Tink-based implementation.

| Crypto Operation | Android | iOS |
|---|---|---|
| AES-256-GCM | Tink `AeadKeyTemplates.AES256_GCM` | `CryptoKit.AES.GCM` with 96-bit IV (12 bytes) — same as Tink default |
| SHA-256 | `MessageDigest.getInstance("SHA-256")` | `CryptoKit.SHA256` |
| ECIES (key exchange) | Tink `HybridKeyTemplates.ECIES_P256_HKDF_HMAC_SHA256_AES128_CTR_HMAC_SHA256` | `CryptoKit.P256.KeyExchange` + HKDF + AES-GCM wrapping — **MUST match wire format** |
| Argon2id | Bouncy Castle | `SwiftArgon2` (Swift package) — **MUST match Android params: m=46MiB, t=3, p=1** |
| Key serialization | Tink JSON keyset format (custom JSON) | Custom JSON key serialization matching Tink's format |

**⚠️ WARNING:** The Tink keyset serialization format is proprietary. iOS must either:
- (A) Use a Tink-in-Swift port (unavailable), OR
- (B) **Skip E2EE for MVP** — treat encryptionVersion=0/1/2 and store answers as non-encrypted plaintext initially, then implement iOS-native crypto that's mutually compatible with Tink as a v2 feature.

**Recommendation:** Skip E2EE for initial iOS port. Store encryptedVersion=0 on new iOS couples. Implement key-compatible encryption in a follow-up batch using `CryptoKit` + interoperability testing.

---

## 11. UI Design System

### Color Palette (from Android theme)

| Token | Hex | Usage |
|---|---|---|
| Primary | `#B98AF4` | Buttons, active states, tab selection |
| Secondary | `#E7A2D1` | Secondary elements, accents |
| Background | `#FFFBFE` | Main background |
| Surface | (Material 3 light surface) | Cards, sheets |
| OnPrimary | `#FFFFFF` | Text on primary |
| Notable: Purple/pink palette, evergreen/gold/danger for meaning/contrast only |

### Visual Guidelines (from UI-PLAN.md)

- **No emoji in chrome** (navigation, cards, game heroes, completion states)
- **No stock photos** — use Material icons + small custom vector glyphs
- **Material 3 aesthetic** — expressive color, shape, motion
- **Category glyphs** for browse/list pages
- **Light, purposeful motion** for game state changes (spin wheel, card swipes)
- **Settings:** quiet, utility-focused
- **Games:** motion + visual identity without childish feel

### iOS Theme Implementation

```swift
struct CloserTheme {
    static let primary = Color(hex: "#B98AF4")
    static let secondary = Color(hex: "#E7A2D1")
    static let background = Color(hex: "#FFFBFE")
    // ... extend with UI-PLAN.md tokens
}
```

### iOS Animation Notes

- **SpinWheel:** `Canvas` view with `drawRect`/`drawPath` + `.rotationEffect()` with animation. Match Android's Canvas-based rendering exactly.
- **DateSwipe (Tinder cards):** `DragGesture` + `offset` + `rotation` modifiers. Use `ZStack` for card deck.
- **Game completions:** Purposeful motion (scale + opacity transitions), not decorative.
- **System:** Prefer `.spring()` animation for interactive transitions.

---

## 12. DI Pattern

Android uses Hilt. iOS should use **manual dependency injection** (no Swinject needed):

```swift
@MainActor
final class AppDependencies {
    // Singletons
    let auth: AuthService
    let firestore: FirestoreService
    let billing: BillingService
    let functions: CloudFunctionsService

    // Repositories
    lazy var userRepository: UserRepository = ...
    lazy var coupleRepository: CoupleRepository = ...
    lazy var questionRepository: QuestionRepository = ...

    init() {
        // Firebase SDKs initialize themselves on first use (plist-based config)
        self.auth = AuthService()
        self.firestore = FirestoreService()
        self.billing = BillingService(apiKey: Secrets.rcApiKey)
        self.functions = CloudFunctionsService()
    }
}

// Pass via environment:
struct AppDependenciesKey: EnvironmentKey {
    static let defaultValue: AppDependencies? = nil
}
extension EnvironmentValues {
    var deps: AppDependencies? {
        get { self[AppDependenciesKey.self] }
        set { self[AppDependenciesKey.self] = newValue }
    }
}
```

---

## 13. Project File Layout for /iphone

```
iphone/
├── ARCHITECTURE_AUDIT.md              ← This file
├── Closer.xcodeproj/                   ← Xcode project
├── Closer/                             ← Main app target
│   ├── CloserApp.swift                 ← @main, WindowGroup, root NavigationStack
│   ├── AppDependencies.swift           ← DI container
│   ├── Secrets.swift                   ← API keys (RevenueCat, etc.)
│   ├── Info.plist
│   ├── GoogleService-Info.plist        ← Firebase config
│   ├── Closer.entitlements
│   │
│   ├── Core/
│   │   ├── Auth/
│   │   │   ├── AuthService.swift           ← Firebase auth wrapper
│   │   │   └── AuthRateLimiter.swift       ← Client-side throttling
│   │   ├── Analytics/
│   │   │   └── AnalyticsService.swift
│   │   ├── Billing/
│   │   │   ├── BillingService.swift        ← RevenueCat wrapper
│   │   │   └── EntitlementChecker.swift    ← Premium status protocol
│   │   └── Notifications/
│   │       └── NotificationService.swift   ← FCM / push
│   │
│   ├── Crypto/                             ← E2EE (v2 — optional MVP)
│   │   ├── SealedAnswerEncryptor.swift
│   │   ├── AnswerCommitment.swift
│   │   ├── UserKeyManager.swift
│   │   ├── CoupleKeyStore.swift
│   │   ├── CoupleEncryptionManager.swift
│   │   ├── RecoveryKeyManager.swift
│   │   ├── PendingAnswerKeyStore.swift
│   │   ├── ReleaseKeyEncryptor.swift
│   │   ├── SealedRevealManager.swift
│   │   └── FieldEncryptor.swift
│   │
│   ├── Models/                             ← Swift domain models
│   │   ├── User.swift
│   │   ├── Couple.swift
│   │   ├── Invite.swift
│   │   ├── Entitlement.swift
│   │   ├── AuthState.swift
│   │   ├── Answer.swift
│   │   ├── DailyQuestion.swift
│   │   ├── Question.swift
│   │   ├── QuestionCategory.swift
│   │   ├── QuestionPack.swift
│   │   ├── QuestionThread.swift
│   │   ├── QuestionMessage.swift
│   │   ├── QuestionReaction.swift
│   │   ├── QuestionSession.swift
│   │   ├── Streak.swift
│   │   ├── DateIdea.swift
│   │   ├── DateMatch.swift
│   │   ├── DateSwipe.swift
│   │   ├── BucketListItem.swift
│   │   ├── ConnectionChallenge.swift
│   │   ├── ChallengeState.swift
│   │   ├── MemoryCapsule.swift
│   │   ├── TimeCapsule.swift
│   │   ├── LocalAnswer.swift
│   │   └── GameType.swift
│   │
│   ├── Services/                           ← Firestore repositories
│   │   ├── FirestoreService.swift          ← Generic query helpers
│   │   ├── UserService.swift
│   │   ├── CoupleService.swift
│   │   ├── InviteService.swift
│   │   ├── QuestionService.swift
│   │   ├── AnswerService.swift
│   │   ├── GameService.swift
│   │   ├── DateService.swift
│   │   └── BucketListService.swift
│   │
│   ├── Navigation/
│   │   ├── AppNavigation.swift         ← TabView + NavigationStacks
│   │   ├── AppRoute.swift              ← Route constants (mirrors AppRoute.kt)
│   │   └── DeepLinkHandler.swift       ← Handle notification deep links
│   │
│   ├── Theme/
│   │   ├── CloserTheme.swift           ← Color, typography, spacing tokens
│   │   ├── ColorExtension.swift
│   │   └── ViewModifiers.swift         ← Common styled modifiers
│   │
│   ├── Components/                         ← Reusable SwiftUI components
│   │   ├── CategoryGlyph.swift             ← Custom vector icons per category
│   │   ├── AnswerCard.swift
│   │   ├── StreakIndicator.swift
│   │   ├── PremiumBadge.swift
│   │   ├── LoadingView.swift
│   │   ├── ErrorView.swift
│   │   ├── EmptyStateView.swift
│   │   ├── PartnerStatusRow.swift
│   │   └── TappableHeartAnimation.swift
│   │
│   ├── Onboarding/
│   │   ├── OnboardingView.swift
│   │   ├── LoginView.swift
│   │   ├── SignUpView.swift
│   │   ├── ForgotPasswordView.swift
│   │   └── CreateProfileView.swift
│   │
│   ├── Pairing/
│   │   ├── PairPromptView.swift
│   │   ├── CreateInviteView.swift
│   │   ├── AcceptInviteView.swift
│   │   ├── EmailInviteView.swift
│   │   ├── InviteConfirmView.swift
│   │   ├── RecoveryView.swift
│   │   └── EncryptionUpgradeView.swift
│   │
│   ├── Home/
│   │   ├── HomeView.swift
│   │   └── PartnerHomeView.swift
│   │
│   ├── Questions/
│   │   ├── DailyQuestionView.swift
│   │   ├── AnswerRevealView.swift
│   │   ├── AnswerHistoryView.swift
│   │   ├── QuestionPackLibraryView.swift
│   │   ├── QuestionCategoryView.swift
│   │   ├── QuestionComposerView.swift
│   │   └── QuestionThreadView.swift
│   │
│   ├── Play/
│   │   ├── PlayHubView.swift
│   │   ├── ThisOrThatView.swift
│   │   ├── HowWellView.swift
│   │   ├── DesireSyncView.swift
│   │   ├── ConnectionChallengesView.swift
│   │   ├── MemoryLaneView.swift
│   │   ├── WaitingForPartnerView.swift
│   │   ├── GameHistoryView.swift
│   │   ├── ThisOrThatReplayView.swift
│   │   ├── DesireSyncReplayView.swift
│   │   └── HowWellReplayView.swift
│   │
│   ├── Wheel/
│   │   ├── CategoryPickerView.swift
│   │   ├── SpinWheelView.swift         ← Canvas-based spinning wheel
│   │   ├── WheelSessionView.swift
│   │   ├── WheelCompleteView.swift
│   │   └── WheelHistoryView.swift
│   │
│   ├── Dates/
│   │   ├── DateMatchView.swift
│   │   ├── DateMatchesView.swift
│   │   ├── DateBuilderView.swift
│   │   └── BucketListView.swift
│   │
│   ├── Settings/
│   │   ├── SettingsView.swift
│   │   ├── AccountView.swift
│   │   ├── NotificationSettingsView.swift
│   │   ├── PrivacyView.swift
│   │   ├── SubscriptionView.swift
│   │   ├── RelationshipSettingsView.swift
│   │   └── DeleteAccountView.swift
│   │
│   ├── Paywall/
│   │   └── PaywallView.swift
│   │
│   └── Resources/
│       ├── Assets.xcassets/
│       ├── questions.json               ← Bundled local question bank
│       └── wordlist.json                ← BIP39-style recovery phrase wordlist
│
├── CloserUITests/
│   └── ...                              ← UI snapshot tests
├── CloserTests/
│   └── ...                              ← Unit tests
│
├── Podfile                              ← Or use Swift Package Manager exclusively
└── README-iOS.md
```

---

## Quick Reference: Key Firebase iOS SDKs (via SPM)

| SDK | Product | Import |
|---|---|---|
| `firebase-ios-sdk/Auth` | Firebase Auth | `import FirebaseAuth` |
| `firebase-ios-sdk/Firestore` | Firestore | `import FirebaseFirestore` |
| `firebase-ios-sdk/Messaging` | FCM | `import FirebaseMessaging` |
| `firebase-ios-sdk/Functions` | Cloud Functions | `import FirebaseFunctions` |
| `firebase-ios-sdk/Storage` | Firebase Storage | `import FirebaseStorage` |
| `RevenueCat/purchases-ios` | RevenueCat | `import RevenueCat` |
| `google/GoogleSignIn-iOS` | Google Sign-In | `import GoogleSignIn` |

---

## Analysis Summary

| Metric | Count |
|---|---|
| Android Kotlin files | 249 |
| Android screens (Composables) | ~48 |
| Firestore top-level collections | 3 (`users`, `couples`, `invites`) |
| Firestore subcollections | ~20 |
| Cloud Functions (callable) | 6 |
| Cloud Functions (triggers) | 4 |
| Cloud Functions (scheduled) | 2 |
| Cloud Functions (webhook) | 1 |
| Domain models to port | 35 |
| Crypto classes to port | 10 (recommended: skip for MVP) |
| Express server files | 11 (not client-facing) |

---

*Generated from Android Kotlin codebase v0.2.0 — use as the single source of truth for iOS development.*