From 67251537eb0e42b8df58d92af9953fa06e8c51c5 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 20 Jun 2026 17:15:25 -0500 Subject: [PATCH] feat(ios): add native SwiftUI iOS app scaffold under /iphone/ (batch 1-6) - ARCHITECTURE_AUDIT.md: full audit covering 49 screens, Firestore schema, 35 domain models, 17 Cloud Functions, RevenueCat integration, auth flow, E2EE skip-for-MVP decision - Project config: project.yml (XcodeGen), Info.plist, Closer.entitlements, Package.swift (SPM) - Core: AuthService (rate limiter), FirestoreService (callable wrappers), BillingService (RevenueCat), NotificationService (FCM) - Models: AuthState (ObservableObject), FirestoreModels (20+ codable types), DomainModels (35 structs) - Theme: CloserTheme (50+ colors, typography, spacing), CommonViews - Screens: Onboarding, pairing, home, daily questions, play hub + games (ThisOrThat, HowWell, DesireSync, ConnectionChallenges), spin wheel (animated 8-slice), dates (swipe cards, bucket list), settings + paywall (RevenueCatUI) --- iphone/ARCHITECTURE_AUDIT.md | 1108 +++++++++++++++++ iphone/Closer/Closer.entitlements | 12 + iphone/Closer/CloserApp.swift | 119 ++ iphone/Closer/Components/CommonViews.swift | 290 +++++ iphone/Closer/Core/Auth/AuthService.swift | 177 +++ .../Closer/Core/Billing/BillingService.swift | 93 ++ .../Notifications/NotificationService.swift | 95 ++ iphone/Closer/Dates/DateViews.swift | 401 ++++++ iphone/Closer/Home/HomeViews.swift | 226 ++++ iphone/Closer/Info.plist | 76 ++ iphone/Closer/Models/AuthState.swift | 108 ++ iphone/Closer/Models/DomainModels.swift | 222 ++++ iphone/Closer/Models/FirestoreModels.swift | 148 +++ iphone/Closer/Navigation/ContentView.swift | 103 ++ .../Closer/Onboarding/OnboardingViews.swift | 516 ++++++++ iphone/Closer/Pairing/PairingViews.swift | 466 +++++++ iphone/Closer/Play/PlayViews.swift | 735 +++++++++++ iphone/Closer/Questions/QuestionViews.swift | 574 +++++++++ iphone/Closer/Services/FirestoreService.swift | 199 +++ iphone/Closer/Settings/SettingsViews.swift | 915 ++++++++++++++ iphone/Closer/Theme/CloserTheme.swift | 175 +++ iphone/Closer/Wheel/WheelViews.swift | 408 ++++++ iphone/Package.swift | 43 + iphone/project.yml | 61 + 24 files changed, 7270 insertions(+) create mode 100644 iphone/ARCHITECTURE_AUDIT.md create mode 100644 iphone/Closer/Closer.entitlements create mode 100644 iphone/Closer/CloserApp.swift create mode 100644 iphone/Closer/Components/CommonViews.swift create mode 100644 iphone/Closer/Core/Auth/AuthService.swift create mode 100644 iphone/Closer/Core/Billing/BillingService.swift create mode 100644 iphone/Closer/Core/Notifications/NotificationService.swift create mode 100644 iphone/Closer/Dates/DateViews.swift create mode 100644 iphone/Closer/Home/HomeViews.swift create mode 100644 iphone/Closer/Info.plist create mode 100644 iphone/Closer/Models/AuthState.swift create mode 100644 iphone/Closer/Models/DomainModels.swift create mode 100644 iphone/Closer/Models/FirestoreModels.swift create mode 100644 iphone/Closer/Navigation/ContentView.swift create mode 100644 iphone/Closer/Onboarding/OnboardingViews.swift create mode 100644 iphone/Closer/Pairing/PairingViews.swift create mode 100644 iphone/Closer/Play/PlayViews.swift create mode 100644 iphone/Closer/Questions/QuestionViews.swift create mode 100644 iphone/Closer/Services/FirestoreService.swift create mode 100644 iphone/Closer/Settings/SettingsViews.swift create mode 100644 iphone/Closer/Theme/CloserTheme.swift create mode 100644 iphone/Closer/Wheel/WheelViews.swift create mode 100644 iphone/Package.swift create mode 100644 iphone/project.yml diff --git a/iphone/ARCHITECTURE_AUDIT.md b/iphone/ARCHITECTURE_AUDIT.md new file mode 100644 index 00000000..6a6fc45d --- /dev/null +++ b/iphone/ARCHITECTURE_AUDIT.md @@ -0,0 +1,1108 @@ +# 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` | `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 + ├── 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 + ├── 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 { 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 + func purchase(package: Package) async -> Result + func restorePurchases() async -> Result + var customerInfo: AsyncStream> { 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.* \ No newline at end of file diff --git a/iphone/Closer/Closer.entitlements b/iphone/Closer/Closer.entitlements new file mode 100644 index 00000000..80ab4d2b --- /dev/null +++ b/iphone/Closer/Closer.entitlements @@ -0,0 +1,12 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)app.closer.iphone + + aps-environment + development + + \ No newline at end of file diff --git a/iphone/Closer/CloserApp.swift b/iphone/Closer/CloserApp.swift new file mode 100644 index 00000000..1696db72 --- /dev/null +++ b/iphone/Closer/CloserApp.swift @@ -0,0 +1,119 @@ +import SwiftUI +import FirebaseCore +import RevenueCat + +@main +struct CloserApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate + @StateObject private var appState = AppState() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(appState) + } + } +} + +// MARK: - App Delegate + +class AppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + FirebaseApp.configure() + + // Configure RevenueCat + Purchases.logLevel = .debug + Purchases.configure(withAPIKey: Secrets.rcApiKey) + + // Configure notifications + NotificationService.shared.configure() + + return true + } + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + Messaging.messaging().apnsToken = deviceToken + NotificationService.shared.updateFCMToken() + } +} + +// MARK: - App State + +@MainActor +final class AppState: ObservableObject { + @Published var authState: AuthState = .loading + @Published var currentUser: User? + @Published var currentCouple: Couple? + @Published var isPremium = false + + private let authService = AuthService.shared + private let firestore = FirestoreService.shared + private var authTask: Task? + + init() { + observeAuthState() + } + + func observeAuthState() { + authTask = Task { + for await state in authService.authStateStream() { + self.authState = state + if case .authenticated(let userId, _) = state { + await loadUserData(userId) + } else { + self.currentUser = nil + self.currentCouple = nil + } + } + } + } + + func loadUserData(_ userId: String) async { + do { + let user: User? = try await firestore.getDocument(at: firestore.userDocument(userId)) + self.currentUser = user + + if let coupleId = user?.coupleId { + let couple: Couple? = try await firestore.getDocument(at: firestore.coupleDocument(coupleId)) + self.currentCouple = couple + } else { + self.currentCouple = nil + } + } catch { + print("Failed to load user data: \(error)") + } + } + + func refreshData() async { + guard case .authenticated(let userId, _) = authState else { return } + await loadUserData(userId) + } + + deinit { + authTask?.cancel() + } +} + +// MARK: - Secrets + +enum Secrets { + /// RevenueCat API key — must be set before building + static let rcApiKey: String = { + guard let key = Bundle.main.object(forInfoDictionaryKey: "RC_API_KEY") as? String, + !key.isEmpty, key != "$(RC_API_KEY)" else { + print("⚠️ RevenueCat API key not configured. Set RC_API_KEY in Info.plist or build settings.") + return "" + } + return key + }() +} + +// MARK: - Import for Messaging + +import FirebaseMessaging \ No newline at end of file diff --git a/iphone/Closer/Components/CommonViews.swift b/iphone/Closer/Components/CommonViews.swift new file mode 100644 index 00000000..79a2de47 --- /dev/null +++ b/iphone/Closer/Components/CommonViews.swift @@ -0,0 +1,290 @@ +import SwiftUI + +// MARK: - Loading View + +struct LoadingView: View { + let message: String + + var body: some View { + VStack(spacing: CloserSpacing.lg) { + ProgressView() + .tint(.closerPrimary) + .scaleEffect(1.2) + Text(message) + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Error View + +struct ErrorView: View { + let message: String + let retryAction: (() -> Void)? + + var body: some View { + VStack(spacing: CloserSpacing.lg) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 48)) + .foregroundColor(.closerWarning) + Text("Something went wrong") + .font(CloserFont.title3) + .foregroundColor(.closerText) + Text(message) + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + + if let retry = retryAction { + Button(action: retry) { + Label("Try Again", systemImage: "arrow.clockwise") + } + .buttonStyle(PrimaryButtonStyle()) + .frame(maxWidth: 200) + } + } + .padding(CloserSpacing.xl) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Empty State View + +struct EmptyStateView: View { + let icon: String + let title: String + let message: String + var action: (title: String, handler: () -> Void)? + + var body: some View { + VStack(spacing: CloserSpacing.lg) { + Image(systemName: icon) + .font(.system(size: 48)) + .foregroundColor(.closerPrimary.opacity(0.6)) + + Text(title) + .font(CloserFont.title3) + .foregroundColor(.closerText) + + Text(message) + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + + if let action = action { + Button(action.title, action: action.handler) + .buttonStyle(PrimaryButtonStyle()) + .frame(maxWidth: 200) + } + } + .padding(CloserSpacing.xl) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Partner Status Row + +struct PartnerStatusRow: View { + let displayName: String + let answered: Bool + let lastActive: Date? + + var body: some View { + HStack(spacing: CloserSpacing.md) { + // Avatar circle + ZStack { + Circle() + .fill(answered ? .closerPrimary : .closerDivider) + .frame(width: 44, height: 44) + Image(systemName: "person.fill") + .foregroundColor(.white) + .font(.system(size: 18)) + } + + VStack(alignment: .leading, spacing: 2) { + Text(displayName) + .font(CloserFont.headline) + .foregroundColor(.closerText) + + if answered { + Label("Answered", systemImage: "checkmark.circle.fill") + .font(CloserFont.caption) + .foregroundColor(.closerSuccess) + } else { + Label("Waiting for answer", systemImage: "clock.fill") + .font(CloserFont.caption) + .foregroundColor(.closerWarning) + } + } + + Spacer() + } + .padding(CloserSpacing.md) + .background(Color.closerSurface) + .cornerRadius(CloserRadius.medium) + } +} + +// MARK: - Premium Badge + +struct PremiumBadge: View { + var body: some View { + HStack(spacing: 4) { + Image(systemName: "crown.fill") + .font(.system(size: 10)) + Text("Premium") + .font(CloserFont.caption) + } + .foregroundColor(.closerGold) + .padding(.horizontal, CloserSpacing.sm) + .padding(.vertical, 4) + .background(Color.closerGold.opacity(0.15)) + .cornerRadius(CloserRadius.full) + } +} + +// MARK: - Streak Indicator + +struct StreakIndicator: View { + let count: Int + let isActive: Bool + + var body: some View { + HStack(spacing: 4) { + Image(systemName: isActive ? "flame.fill" : "flame") + .foregroundColor(isActive ? .closerDanger : .closerTextSecondary) + Text("\(count) day\(count == 1 ? "" : "s")") + .font(CloserFont.footnote) + .foregroundColor(.closerTextSecondary) + } + .padding(.horizontal, CloserSpacing.sm) + .padding(.vertical, 4) + .background(isActive ? Color.closerDanger.opacity(0.1) : Color.closerDivider.opacity(0.3)) + .cornerRadius(CloserRadius.full) + } +} + +// MARK: - Category Glyph + +struct CategoryGlyph: View { + let name: String + let color: Color + var isLarge: Bool = false + + var body: some View { + ZStack { + Circle() + .fill(color.opacity(0.15)) + .frame(width: isLarge ? 64 : 48, height: isLarge ? 64 : 48) + + Image(systemName: iconForCategory(name)) + .font(.system(size: isLarge ? 24 : 18)) + .foregroundColor(color) + } + } + + private func iconForCategory(_ name: String) -> String { + switch name.lowercased() { + case let s where s.contains("communication"): return "bubble.left.and.bubble.right.fill" + case let s where s.contains("intimacy"): return "heart.fill" + case let s where s.contains("fun"): return "gamecontroller.fill" + case let s where s.contains("goal"): return "target" + case let s where s.contains("adventure"): return "paperplane.fill" + case let s where s.contains("romance"): return "sparkles" + case let s where s.contains("deep"): return "brain.head.profile" + default: return "questionmark.circle.fill" + } + } +} + +// MARK: - Answer Card + +struct AnswerCard: View { + let text: String + let date: Date + let isRevealed: Bool + let onTap: (() -> Void)? + + var body: some View { + Button(action: { onTap?() }) { + VStack(alignment: .leading, spacing: CloserSpacing.sm) { + HStack { + Text(date, style: .date) + .font(CloserFont.caption) + .foregroundColor(.closerTextSecondary) + Spacer() + if isRevealed { + Image(systemName: "eye.fill") + .foregroundColor(.closerPrimary) + } else { + Image(systemName: "eye.slash.fill") + .foregroundColor(.closerTextSecondary) + } + } + + Text(isRevealed ? text : "Tap to reveal partner's answer") + .font(CloserFont.body) + .foregroundColor(isRevealed ? .closerText : .closerTextSecondary) + .lineLimit(3) + } + .padding(CloserSpacing.md) + .background(Color.closerSurface) + .cornerRadius(CloserRadius.medium) + } + .buttonStyle(.plain) + } +} + +// MARK: - Heart Animation View + +struct HeartBurstView: View { + @State private var animate = false + + var body: some View { + Image(systemName: "heart.fill") + .font(.system(size: 80)) + .foregroundColor(.closerDanger) + .scaleEffect(animate ? 1.2 : 0.5) + .opacity(animate ? 0.8 : 0) + .animation(.spring(response: 0.6, dampingFraction: 0.5).repeatCount(1, autoreverses: true), value: animate) + .onAppear { animate = true } + } +} + +// MARK: - Premium Feature Gate + +struct PremiumGateView: View { + let featureName: String + let onUnlock: () -> Void + let onDismiss: () -> Void + + var body: some View { + VStack(spacing: CloserSpacing.xl) { + Image(systemName: "crown.fill") + .font(.system(size: 64)) + .foregroundColor(.closerGold) + + Text("Unlock \(featureName)") + .font(CloserFont.title2) + .foregroundColor(.closerText) + + Text("This feature requires a premium subscription. Upgrade to access all games, unlimited questions, and more.") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + + Button("See Plans", action: onUnlock) + .buttonStyle(PrimaryButtonStyle()) + + Button("Maybe Later", action: onDismiss) + .buttonStyle(SecondaryButtonStyle()) + } + .padding(CloserSpacing.xxl) + .background(Color.closerBackground) + .cornerRadius(CloserRadius.xlarge) + .closerShadow(level: .large) + .padding(CloserSpacing.xl) + } +} \ No newline at end of file diff --git a/iphone/Closer/Core/Auth/AuthService.swift b/iphone/Closer/Core/Auth/AuthService.swift new file mode 100644 index 00000000..d85fd974 --- /dev/null +++ b/iphone/Closer/Core/Auth/AuthService.swift @@ -0,0 +1,177 @@ +import Foundation +import FirebaseAuth +import FirebaseCore +import GoogleSignIn + +// MARK: - Auth Service + +final class AuthService: NSObject, @unchecked Sendable { + static let shared = AuthService() + + private let auth = Auth.auth() + private let limiter = AuthRateLimiter.shared + + private override init() { + super.init() + } + + // MARK: - Auth State + + var currentUserId: String? { auth.currentUser?.uid } + var currentUserEmail: String? { auth.currentUser?.email } + var isSignedIn: Bool { auth.currentUser != nil } + var isAnonymous: Bool { auth.currentUser?.isAnonymous ?? false } + var isGoogleAccount: Bool { + auth.currentUser?.providerData.contains { $0.providerID == GoogleAuthProviderID } ?? false + } + + /// Observe auth state changes as an async sequence + func authStateStream() -> AsyncStream { + AsyncStream { continuation in + let listener = Auth.auth().addStateDidChangeListener { _, user in + if let user = user { + continuation.yield(.authenticated(userId: user.uid, isAnonymous: user.isAnonymous)) + } else { + continuation.yield(.unauthenticated) + } + } + continuation.onTermination = { _ in + Auth.auth().removeStateDidChangeListener(listener) + } + } + } + + // MARK: - Auth Operations + + func signInAnonymously() async throws -> String { + _ = limiter.recordFailure(.anonymous) + guard limiter.timeUntilNextAttempt(.anonymous) == 0 else { + throw AuthError.throttled(limiter.throttleMessage(.anonymous) ?? "Too many attempts. Please wait.") + } + + let result = try await auth.signInAnonymously() + limiter.recordSuccess(.anonymous) + return result.user.uid + } + + func signInWithEmail(_ email: String, password: String) async throws -> String { + guard limiter.timeUntilNextAttempt(.login) == 0 else { + throw AuthError.throttled(limiter.throttleMessage(.login) ?? "Too many attempts. Please wait.") + } + + do { + let result = try await auth.signIn(withEmail: email, password: password) + limiter.recordSuccess(.login) + return result.user.uid + } catch { + limiter.recordFailure(.login) + throw AuthError.map(error) + } + } + + func signUpWithEmail(_ email: String, password: String) async throws -> String { + guard limiter.timeUntilNextAttempt(.signUp) == 0 else { + throw AuthError.throttled(limiter.throttleMessage(.signUp) ?? "Too many attempts. Please wait.") + } + + do { + let result = try await auth.createUser(withEmail: email, password: password) + limiter.recordSuccess(.signUp) + return result.user.uid + } catch { + limiter.recordFailure(.signUp) + throw AuthError.map(error) + } + } + + func sendPasswordResetEmail(_ email: String) async throws { + guard limiter.timeUntilNextAttempt(.passwordReset) == 0 else { + throw AuthError.throttled(limiter.throttleMessage(.passwordReset) ?? "Too many attempts. Please wait.") + } + + do { + try await auth.sendPasswordResetEmail(withEmail: email) + limiter.recordSuccess(.passwordReset) + } catch { + limiter.recordFailure(.passwordReset) + throw AuthError.map(error) + } + } + + func signInWithGoogle(idToken: String, accessToken: String) async throws -> GoogleSignInResult { + let credential = GoogleAuthProvider.credential(withIDToken: idToken, accessToken: accessToken) + let result = try await auth.signIn(with: credential) + + let user = result.user + return GoogleSignInResult( + uid: user.uid, + displayName: user.displayName ?? "", + photoUrl: user.photoURL?.absoluteString ?? "", + email: user.email ?? "", + isAnonymous: user.isAnonymous + ) + } + + func signOut() throws { + try auth.signOut() + } + + func reauthenticateWithEmail(_ email: String, password: String) async throws { + guard let currentUser = auth.currentUser else { + throw AuthError.notSignedIn + } + let credential = EmailAuthProvider.credential(withEmail: email, password: password) + try await currentUser.reauthenticate(with: credential) + } + + func deleteAccount() async throws { + guard let currentUser = auth.currentUser else { + throw AuthError.notSignedIn + } + try await currentUser.delete() + } +} + +// MARK: - Auth Errors + +enum AuthError: LocalizedError { + case throttled(String) + case notSignedIn + case networkError + case invalidCredential + case emailAlreadyInUse + case weakPassword + case userNotFound + case unknown(Error) + + static func map(_ error: Error) -> AuthError { + let nsError = error as NSError + switch nsError.code { + case AuthErrorCode.networkError.rawValue: + return .networkError + case AuthErrorCode.invalidCredential.rawValue: + return .invalidCredential + case AuthErrorCode.emailAlreadyInUse.rawValue: + return .emailAlreadyInUse + case AuthErrorCode.weakPassword.rawValue: + return .weakPassword + case AuthErrorCode.userNotFound.rawValue: + return .userNotFound + default: + return .unknown(error) + } + } + + var errorDescription: String? { + switch self { + case .throttled(let msg): return msg + case .notSignedIn: return "No user is signed in." + case .networkError: return "Network error. Please check your connection." + case .invalidCredential: return "Invalid email or password." + case .emailAlreadyInUse: return "This email is already registered." + case .weakPassword: return "Password must be at least 6 characters." + case .userNotFound: return "No account found with this email." + case .unknown(let error): return error.localizedDescription + } + } +} \ No newline at end of file diff --git a/iphone/Closer/Core/Billing/BillingService.swift b/iphone/Closer/Core/Billing/BillingService.swift new file mode 100644 index 00000000..19abc0d8 --- /dev/null +++ b/iphone/Closer/Core/Billing/BillingService.swift @@ -0,0 +1,93 @@ +import Foundation +import RevenueCat + +// MARK: - Billing Service + +final class BillingService: @unchecked Sendable { + static let shared = BillingService() + + private var isConfigured = false + private var customerInfoListener: Task? + + private init() {} + + func configure(with apiKey: String) { + guard !isConfigured else { return } + Purchases.logLevel = .debug + Purchases.configure(withAPIKey: apiKey) + isConfigured = true + } + + /// Fetch available offerings for the paywall + func getOfferings() async throws -> Offerings { + try await Purchases.shared.offerings() + } + + /// Purchase a package + func purchase(_ package: Package) async throws { + let result = try await Purchases.shared.purchase(package: package) + // After successful purchase, sync entitlement with server + try await FirestoreService.shared.syncEntitlementCallable() + } + + /// Restore previous purchases + func restorePurchases() async throws -> CustomerInfo { + try await Purchases.shared.restorePurchases() + } + + /// Reactive stream of customer info + var customerInfoStream: AsyncStream { + AsyncStream { continuation in + let task = Task { + for await info in Purchases.shared.customerInfoStream { + continuation.yield(info) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + /// Check if current user has premium entitlement + func checkPremiumStatus() async -> Bool { + do { + let customerInfo = try await Purchases.shared.customerInfo() + return customerInfo.entitlements["closer_premium"]?.isActive == true + } catch { + return false + } + } +} + +// MARK: - Entitlement Checker + +protocol EntitlementChecker: Actor { + var isPremium: AsyncStream { get } + func hasPremium() async -> Bool +} + +final actor DefaultEntitlementChecker: EntitlementChecker { + nonisolated let isPremium: AsyncStream + private let billing: BillingService + private let firestore: FirestoreService + + init(billing: BillingService = .shared, firestore: FirestoreService = .shared) { + self.billing = billing + self.firestore = firestore + + // Create async stream combining RevenueCat + Firestore + self.isPremium = AsyncStream { continuation in + let task = Task { + // Listen to RevenueCat changes + for await _ in billing.customerInfoStream { + let premium = await billing.checkPremiumStatus() + continuation.yield(premium) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + func hasPremium() async -> Bool { + await billing.checkPremiumStatus() + } +} \ No newline at end of file diff --git a/iphone/Closer/Core/Notifications/NotificationService.swift b/iphone/Closer/Core/Notifications/NotificationService.swift new file mode 100644 index 00000000..a812b48e --- /dev/null +++ b/iphone/Closer/Core/Notifications/NotificationService.swift @@ -0,0 +1,95 @@ +import Foundation +import FirebaseMessaging +import UserNotifications + +// MARK: - Notification Service + +final class NotificationService: NSObject, @unchecked Sendable { + static let shared = NotificationService() + + private override init() { + super.init() + } + + func configure() { + UNUserNotificationCenter.current().delegate = self + registerForPushNotifications() + } + + private func registerForPushNotifications() { + let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] + UNUserNotificationCenter.current().requestAuthorization(options: authOptions) { granted, error in + guard granted else { return } + Task { @MainActor in + UIApplication.shared.registerForRemoteNotifications() + } + } + } + + func updateFCMToken() { + if let token = Messaging.messaging().fcmToken { + Task { + await saveTokenToFirestore(token) + } + } + } + + private func saveTokenToFirestore(_ token: String) async { + guard let userId = try? FirestoreService.shared.userId() else { return } + + let tokenRef = FirestoreService.shared.fcmTokensRef(userId).document(token) + try? await tokenRef.setData([ + "token": token, + "updatedAt": FieldValue.serverTimestamp() + ], merge: true) + } +} + +// MARK: - UNUserNotificationCenterDelegate + +extension NotificationService: UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + // Handle notification tap — deep link to relevant screen + let userInfo = response.notification.request.content.userInfo + handleDeepLink(userInfo) + completionHandler() + } + + private func handleDeepLink(_ userInfo: [AnyHashable: Any]) { + // Parse deep link from notification payload + guard let type = userInfo["type"] as? String else { return } + + switch type { + case "daily_question": + NotificationCenter.default.post(name: .navigateToDailyQuestion, object: nil) + case "partner_answered": + NotificationCenter.default.post(name: .navigateToReveal, object: userInfo["questionId"]) + case "streak": + NotificationCenter.default.post(name: .navigateToHome, object: nil) + default: + break + } + } +} + +// MARK: - Notification Names + +extension Notification.Name { + static let navigateToDailyQuestion = Notification.Name("navigateToDailyQuestion") + static let navigateToReveal = Notification.Name("navigateToReveal") + static let navigateToHome = Notification.Name("navigateToHome") +} + +typealias FieldValue = FirebaseFirestore.FieldValue \ No newline at end of file diff --git a/iphone/Closer/Dates/DateViews.swift b/iphone/Closer/Dates/DateViews.swift new file mode 100644 index 00000000..ef2301e1 --- /dev/null +++ b/iphone/Closer/Dates/DateViews.swift @@ -0,0 +1,401 @@ +import SwiftUI + +// MARK: - Date Match (Swipe) + +struct DateMatchView: View { + @State private var currentIndex = 0 + @State private var offset: CGSize = .zero + @State private var showMatch = false + @State private var matched: DateIdea? + + let dateIdeas: [DateIdea] = [ + DateIdea(id: "1", title: "Sunset Picnic", description: "Pack a basket and watch the sunset together at your favorite spot.", category: "romance", cost: "low", duration: "medium", location: "outdoor"), + DateIdea(id: "2", title: "Cooking Challenge", description: "Pick a cuisine you've never tried and cook it together.", category: "fun", cost: "medium", duration: "long", location: "indoor"), + DateIdea(id: "3", title: "Board Game Night", description: "Pull out your favorite board games and make it a tournament.", category: "fun", cost: "free", duration: "medium", location: "indoor"), + DateIdea(id: "4", title: "Stargazing", description: "Find a dark spot, bring blankets, and watch the stars.", category: "romance", cost: "free", duration: "medium", location: "outdoor"), + DateIdea(id: "5", title: "Art Class Together", description: "Take a pottery or painting class as a couple.", category: "creative", cost: "medium", duration: "long", location: "indoor"), + ] + + var body: some View { + VStack(spacing: CloserSpacing.xl) { + if showMatch, let idea = matched { + VStack(spacing: CloserSpacing.lg) { + Image(systemName: "heart.fill") + .font(.system(size: 72)) + .foregroundColor(.closerDanger) + Text("It's a Match!") + .font(CloserFont.title1) + .foregroundColor(.closerText) + Text(idea.title) + .font(CloserFont.title2) + .foregroundColor(.closerPrimary) + Text(idea.description ?? "") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + + Button("Plan This Date") { + // Navigate to date builder + } + .buttonStyle(PrimaryButtonStyle()) + + Button("Keep Swiping") { + withAnimation { showMatch = false } + } + .buttonStyle(SecondaryButtonStyle()) + } + } else { + // Card stack + ZStack { + ForEach(dateIdeas.indices, id: \.self) { index in + if index >= currentIndex && index < currentIndex + 3 { + DateSwipeCard( + idea: dateIdeas[index], + offset: index == currentIndex ? $offset : .constant(.zero), + isTop: index == currentIndex + ) + .scaleEffect(index == currentIndex ? 1 : 1 - CGFloat(index - currentIndex) * 0.05) + .offset(y: index == currentIndex ? 0 : CGFloat(index - currentIndex) * 10) + } + } + } + .frame(height: 400) + + // Action buttons + HStack(spacing: CloserSpacing.xxl) { + Button(action: { swipe(.left) }) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 56)) + .foregroundColor(.closerDanger) + } + + Button(action: { swipe(.right) }) { + Image(systemName: "heart.circle.fill") + .font(.system(size: 56)) + .foregroundColor(.closerSuccess) + } + } + + Text("Swipe right to match, left to pass") + .font(CloserFont.caption) + .foregroundColor(.closerTextSecondary) + } + } + .closerPadding() + .background(Color.closerBackground) + .navigationTitle("Date Ideas") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(isPresented: .constant(false)) { + DateBuilderView() + } + } + + private func swipe(_ direction: SwipeDirection) { + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() + + withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { + offset = direction == .right ? CGSize(width: 500, height: 0) : CGSize(width: -500, height: 0) + } + + if direction == .right { + matched = dateIdeas[currentIndex] + showMatch = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + offset = .zero + currentIndex += 1 + if currentIndex >= dateIdeas.count { + currentIndex = 0 + } + } + } + + enum SwipeDirection { + case left, right + } +} + +// MARK: - Date Swipe Card + +struct DateSwipeCard: View { + let idea: DateIdea + @Binding var offset: CGSize + let isTop: Bool + + var body: some View { + VStack(alignment: .leading, spacing: CloserSpacing.md) { + // Icon area + ZStack { + RoundedRectangle(cornerRadius: CloserRadius.large) + .fill(Color.closerPrimary.opacity(0.1)) + .frame(height: 200) + + Image(systemName: iconForDate(idea)) + .font(.system(size: 60)) + .foregroundColor(.closerPrimary) + } + + VStack(alignment: .leading, spacing: CloserSpacing.sm) { + Text(idea.title) + .font(CloserFont.title2) + .foregroundColor(.closerText) + + Text(idea.description ?? "") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .lineLimit(3) + + HStack(spacing: CloserSpacing.md) { + TagView(text: idea.cost ?? "") + TagView(text: idea.location ?? "") + TagView(text: idea.duration ?? "") + } + } + .padding(CloserSpacing.md) + } + .closerCard() + .offset(isTop ? offset : .zero) + .rotationEffect(isTop ? .degrees(Double(offset.width / 20)) : .zero) + .gesture(isTop ? DragGesture() + .onChanged { value in offset = value.translation } + .onEnded { _ in + if abs(offset.width) > 120 { + // Let swipe handler in parent manage this + } else { + withAnimation(.spring()) { offset = .zero } + } + } + : nil) + } + + private func iconForDate(_ idea: DateIdea) -> String { + switch idea.category { + case "romance": return "heart.fill" + case "fun": return "gamecontroller.fill" + case "creative": return "paintbrush.fill" + case "adventure": return "paperplane.fill" + default: return "star.fill" + } + } +} + +// MARK: - Date Matches + +struct DateMatchesView: View { + @State private var matches: [DateIdea] = [] + @State private var isLoading = true + + var body: some View { + List { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + } else if matches.isEmpty { + EmptyStateView( + icon: "heart.slash", + title: "No Matches Yet", + message: "Swipe on date ideas to find mutual matches with your partner!", + action: (title: "Browse Ideas", handler: {}) + ) + .listRowBackground(Color.clear) + } else { + ForEach(matches, id: \.id) { match in + HStack(spacing: CloserSpacing.md) { + Image(systemName: "heart.fill") + .foregroundColor(.closerDanger) + VStack(alignment: .leading, spacing: 4) { + Text(match.title) + .font(CloserFont.body) + Text(match.description ?? "") + .font(CloserFont.caption) + .foregroundColor(.closerTextSecondary) + .lineLimit(2) + } + } + .padding(.vertical, 4) + } + } + } + .listStyle(.insetGrouped) + .background(Color.closerBackground) + .navigationTitle("Date Matches") + .navigationBarTitleDisplayMode(.inline) + .task { + try? await Task.sleep(nanoseconds: 500_000_000) + isLoading = false + } + } +} + +// MARK: - Date Builder + +struct DateBuilderView: View { + @State private var dateTitle = "" + @State private var dateDescription = "" + @State private var dateLocation = "" + @State private var selectedDate = Date() + @State private var isLoading = false + + var body: some View { + Form { + Section("Date Details") { + TextField("Title", text: $dateTitle) + TextField("Description (optional)", text: $dateDescription, axis: .vertical) + .lineLimit(3) + TextField("Location (optional)", text: $dateLocation) + } + + Section("Date & Time") { + DatePicker("Date", selection: $selectedDate, displayedComponents: [.date, .hourAndMinute]) + } + + Section { + Button(action: savePlan) { + if isLoading { + ProgressView() + } else { + Text("Save Date Plan") + } + } + .disabled(dateTitle.isEmpty || isLoading) + .frame(maxWidth: .infinity) + } + } + .background(Color.closerBackground) + .navigationTitle("Plan a Date") + .navigationBarTitleDisplayMode(.inline) + } + + private func savePlan() { + isLoading = true + Task { + try? await Task.sleep(nanoseconds: 500_000_000) + isLoading = false + } + } +} + +// MARK: - Bucket List + +struct BucketListView: View { + @State private var items: [BucketListItem] = [] + @State private var isLoading = true + @State private var showAdd = false + @State private var newItemTitle = "" + @State private var newItemDescription = "" + + var body: some View { + List { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + } else if items.isEmpty { + EmptyStateView( + icon: "list.bullet", + title: "Your Bucket List is Empty", + message: "Add things you want to do together as a couple!", + action: (title: "Add Item", handler: { showAdd = true }) + ) + .listRowBackground(Color.clear) + } else { + ForEach(items) { item in + HStack { + Image(systemName: item.completed ? "checkmark.circle.fill" : "circle") + .foregroundColor(item.completed ? .closerSuccess : .closerDivider) + .onTapGesture { + toggleItem(item) + } + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(CloserFont.body) + .strikethrough(item.completed) + .foregroundColor(item.completed ? .closerTextSecondary : .closerText) + if let desc = item.description { + Text(desc) + .font(CloserFont.caption) + .foregroundColor(.closerTextSecondary) + } + } + } + .padding(.vertical, 4) + } + } + } + .listStyle(.insetGrouped) + .background(Color.closerBackground) + .navigationTitle("Our Bucket List") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: { showAdd = true }) { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showAdd) { + NavigationStack { + Form { + TextField("Title", text: $newItemTitle) + TextField("Description (optional)", text: $newItemDescription, axis: .vertical) + .lineLimit(3) + } + .navigationTitle("Add Item") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { showAdd = false } + } + ToolbarItem(placement: .confirmationAction) { + Button("Add") { addItem() } + .disabled(newItemTitle.isEmpty) + } + } + } + } + .task { + try? await Task.sleep(nanoseconds: 500_000_000) + isLoading = false + } + } + + private func addItem() { + let item = BucketListItem( + id: UUID().uuidString, + title: newItemTitle, + description: newItemDescription.isEmpty ? nil : newItemDescription, + createdBy: AuthService.shared.currentUserId ?? "", + completed: false, + completedAt: nil, + createdAt: Date() + ) + items.append(item) + newItemTitle = "" + newItemDescription = "" + showAdd = false + } + + private func toggleItem(_ item: BucketListItem) { + if let index = items.firstIndex(where: { $0.id == item.id }) { + items[index].completed.toggle() + items[index].completedAt = items[index].completed ? Date() : nil + } + } +} + +// MARK: - Helper Views + +struct TagView: View { + let text: String + + var body: some View { + Text(text.capitalized) + .font(CloserFont.caption) + .foregroundColor(.closerTextSecondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.closerDivider.opacity(0.5)) + .cornerRadius(CloserRadius.full) + } +} \ No newline at end of file diff --git a/iphone/Closer/Home/HomeViews.swift b/iphone/Closer/Home/HomeViews.swift new file mode 100644 index 00000000..75fac76e --- /dev/null +++ b/iphone/Closer/Home/HomeViews.swift @@ -0,0 +1,226 @@ +import SwiftUI + +// MARK: - Home View + +struct HomeView: View { + @EnvironmentObject var appState: AppState + @State private var showPartnerHome = false + @State private var showBucketList = false + + var body: some View { + ScrollView { + VStack(spacing: CloserSpacing.xl) { + // Welcome header + VStack(alignment: .leading, spacing: 4) { + Text("Welcome back") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + Text(appState.currentUser?.displayName ?? "Partner") + .font(CloserFont.title1) + .foregroundColor(.closerText) + } + .frame(maxWidth: .infinity, alignment: .leading) + .closerPadding() + + // Streak card + StreakCard() + .closerPadding() + + // Quick actions + VStack(alignment: .leading, spacing: CloserSpacing.md) { + Text("Today") + .closerSectionTitle() + .closerPadding() + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: CloserSpacing.md) { + QuickActionCard( + icon: "heart.fill", + title: "Daily Question", + subtitle: "Connect with your partner", + color: .closerPrimary + ) + + QuickActionCard( + icon: "gamecontroller.fill", + title: "Play Together", + subtitle: "Games & challenges", + color: .closerSecondary + ) + + QuickActionCard( + icon: "sparkles", + title: "Date Ideas", + subtitle: "Plan something fun", + color: .closerGold + ) + } + .padding(.horizontal, CloserSpacing.xl) + } + } + + // Partner status + VStack(alignment: .leading, spacing: CloserSpacing.sm) { + Text("Partner") + .closerSectionTitle() + .closerPadding() + + PartnerStatusRow( + displayName: "Your Partner", + answered: false, + lastActive: nil + ) + .closerPadding() + } + + // Relationship summary + VStack(alignment: .leading, spacing: CloserSpacing.sm) { + Text("Your Journey") + .closerSectionTitle() + .closerPadding() + + VStack(spacing: CloserSpacing.sm) { + StatRow(icon: "flame.fill", label: "Streak", value: "\(appState.currentCouple?.streakCount ?? 0) days") + StatRow(icon: "questionmark.bubble.fill", label: "Questions Answered", value: "12") + StatRow(icon: "gamecontroller.fill", label: "Games Played", value: "5") + } + .closerPadding() + } + + // Bottom nav to partner home + Button(action: { showPartnerHome = true }) { + Label("View Partner's Activity", systemImage: "person.fill") + } + .buttonStyle(SecondaryButtonStyle()) + .closerPadding() + + Spacer() + .frame(height: CloserSpacing.xxl) + } + } + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(isPresented: $showPartnerHome) { + PartnerHomeView() + } + .navigationDestination(isPresented: $showBucketList) { + BucketListView() + } + } +} + +// MARK: - Partner Home + +struct PartnerHomeView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + ScrollView { + VStack(spacing: CloserSpacing.xl) { + VStack(alignment: .leading, spacing: 4) { + Text("Partner's Activity") + .font(CloserFont.title1) + .foregroundColor(.closerText) + Text("See what your partner has been up to") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .closerPadding() + + EmptyStateView( + icon: "person.crop.circle.badge.questionmark", + title: "No Recent Activity", + message: "Activity updates will appear here when your partner answers questions or plays games." + ) + } + } + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Subviews + +struct StreakCard: View { + @EnvironmentObject var appState: AppState + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Couple Streak") + .font(CloserFont.subheadline) + .foregroundColor(.closerTextSecondary) + HStack(spacing: 8) { + Image(systemName: "flame.fill") + .foregroundColor(.closerDanger) + Text("\(appState.currentCouple?.streakCount ?? 0) days") + .font(CloserFont.title2) + .foregroundColor(.closerText) + } + } + Spacer() + + // Streak dots + HStack(spacing: 6) { + ForEach(0..<7) { i in + Circle() + .fill(i < (appState.currentCouple?.streakCount ?? 0) % 7 ? Color.closerDanger : Color.closerDivider) + .frame(width: 12, height: 12) + } + } + } + .padding(CloserSpacing.lg) + .closerCard() + } +} + +struct QuickActionCard: View { + let icon: String + let title: String + let subtitle: String + let color: Color + + var body: some View { + VStack(alignment: .leading, spacing: CloserSpacing.sm) { + Image(systemName: icon) + .font(.title2) + .foregroundColor(color) + + Text(title) + .font(CloserFont.headline) + .foregroundColor(.closerText) + + Text(subtitle) + .font(CloserFont.caption) + .foregroundColor(.closerTextSecondary) + } + .padding(CloserSpacing.lg) + .frame(width: 160, alignment: .leading) + .closerCard() + } +} + +struct StatRow: View { + let icon: String + let label: String + let value: String + + var body: some View { + HStack { + Image(systemName: icon) + .foregroundColor(.closerPrimary) + .frame(width: 24) + Text(label) + .font(CloserFont.callout) + .foregroundColor(.closerText) + Spacer() + Text(value) + .font(CloserFont.headline) + .foregroundColor(.closerText) + } + .padding(CloserSpacing.md) + .background(Color.closerSurface) + .cornerRadius(CloserRadius.medium) + } +} \ No newline at end of file diff --git a/iphone/Closer/Info.plist b/iphone/Closer/Info.plist new file mode 100644 index 00000000..537c6011 --- /dev/null +++ b/iphone/Closer/Info.plist @@ -0,0 +1,76 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Closer + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.2.0 + CFBundleURLTypes + + + CFBundleURLName + com.google.ReversedClientID + CFBundleURLSchemes + + $(GOOGLE_REVERSED_CLIENT_ID) + + + + CFBundleURLName + app.closer.iphone + CFBundleURLSchemes + + closer + + + + CFBundleVersion + 1 + FirebaseAppDelegateProxyEnabled + + LSRequiresIPhoneOS + + NSCameraUsageDescription + Take a profile photo or add photos to memory capsules. + NSPhotoLibraryUsageDescription + Choose photos for your profile or memory capsules. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIBackgroundModes + + fetch + remote-notification + + UILaunchScreen + + UIColorName + LaunchBackground + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UIUserInterfaceStyle + Light + + \ No newline at end of file diff --git a/iphone/Closer/Models/AuthState.swift b/iphone/Closer/Models/AuthState.swift new file mode 100644 index 00000000..fdaeebe2 --- /dev/null +++ b/iphone/Closer/Models/AuthState.swift @@ -0,0 +1,108 @@ +import Foundation + +// MARK: - Auth State + +enum AuthState: Equatable { + case loading + case authenticated(userId: String, isAnonymous: Bool) + case unauthenticated +} + +// MARK: - Auth Rate Limiter + +/// Per-session client-side rate limiter for Firebase Auth operations. +/// Mirrors the Android AuthRateLimiter.kt implementation. +final class AuthRateLimiter: @unchecked Sendable { + static let shared = AuthRateLimiter() + + enum Flow: String, CaseIterable { + case login, signUp, passwordReset, anonymous + } + + private struct AttemptState { + var failures: Int = 0 + var lockoutEnd: Date? + } + + private let lock = NSLock() + private var states: [Flow: AttemptState] = [:] + + // Constants matching Android + let softLimitFailures = 3 + let maxFailuresBeforeLockout = 5 + let lockoutDuration: TimeInterval = 30 + let maxBackoff: TimeInterval = 8 + let baseBackoff: TimeInterval = 1 + let backoffExponentBase: Double = 2.0 + + private init() {} + + // How long to wait before next attempt, in seconds + func timeUntilNextAttempt(_ flow: Flow = .login) -> TimeInterval { + lock.lock() + defer { lock.unlock() } + + guard let state = states[flow] else { return 0 } + + // Check hard lockout + if let lockoutEnd = state.lockoutEnd, Date() < lockoutEnd { + return lockoutEnd.timeIntervalSinceNow + } + + return computeBackoffDelay(state.failures) + } + + @discardableResult + func recordFailure(_ flow: Flow = .login) -> TimeInterval { + lock.lock() + defer { lock.unlock() } + + var state = states[flow] ?? AttemptState() + state.failures += 1 + + if state.failures >= maxFailuresBeforeLockout { + state.lockoutEnd = Date().addingTimeInterval(lockoutDuration) + } + + states[flow] = state + + if let lockoutEnd = state.lockoutEnd, Date() < lockoutEnd { + return lockoutEnd.timeIntervalSinceNow + } + return computeBackoffDelay(state.failures) + } + + func recordSuccess(_ flow: Flow = .login) { + lock.lock() + defer { lock.unlock() } + states.removeValue(forKey: flow) + } + + var isThrottled: Bool { + timeUntilNextAttempt() > 0 + } + + func throttleMessage(_ flow: Flow = .login) -> String? { + let wait = timeUntilNextAttempt(flow) + guard wait > 0 else { return nil } + let seconds = Int(ceil(wait)) + return "Too many attempts. Try again in \(seconds) second\(seconds == 1 ? "" : "s")." + } + + private func computeBackoffDelay(_ failures: Int) -> TimeInterval { + guard failures >= softLimitFailures else { return 0 } + let exponent = failures - softLimitFailures + let raw = baseBackoff * pow(backoffExponentBase, Double(exponent)) + return min(raw, maxBackoff) + } +} + +// MARK: - Google Sign-In Result + +struct GoogleSignInResult { + let uid: String + let displayName: String + let photoUrl: String + let email: String + let isAnonymous: Bool +} \ No newline at end of file diff --git a/iphone/Closer/Models/DomainModels.swift b/iphone/Closer/Models/DomainModels.swift new file mode 100644 index 00000000..bb8be244 --- /dev/null +++ b/iphone/Closer/Models/DomainModels.swift @@ -0,0 +1,222 @@ +import Foundation +import SwiftUI + +// MARK: - Question + +struct Question: Codable, Identifiable, Hashable, Sendable { + let id: String + var text: String + var type: String // "text" | "multiple_choice" | "scale" + var options: [String]? + var scaleMin: Int? + var scaleMax: Int? + var categoryId: String? + var packId: String? +} + +struct QuestionCategory: Codable, Identifiable, Hashable, Sendable { + let id: String + var name: String + var description: String? + var icon: String? + var color: String? + var packId: String? + var order: Int? +} + +struct QuestionPack: Codable, Identifiable, Hashable, Sendable { + let id: String + var name: String + var description: String? + var categories: [String]? + var isPremium: Bool +} + +// MARK: - Question Threads + +struct QuestionThread: Codable, Identifiable, Sendable { + let id: String + var questionId: String + var senderId: String + var status: String // "pending" | "answered" | "revealed" + var createdAt: Date +} + +struct QuestionMessage: Codable, Identifiable, Sendable { + let id: String + var threadId: String + var userId: String + var text: String + var createdAt: Date +} + +struct QuestionReaction: Codable, Identifiable, Sendable { + let id: String + var threadId: String + var userId: String + var emoji: String + var createdAt: Date +} + +struct QuestionSession: Codable, Identifiable, Sendable { + let id: String + var gameType: String + var status: String + var startedAt: Date + var completedAt: Date? + var createdBy: String +} + +// MARK: - Date Ideas + +struct DateIdea: Codable, Identifiable, Hashable, Sendable { + let id: String + var title: String + var description: String? + var category: String? + var cost: String? // "free" | "low" | "medium" | "high" + var duration: String? // "short" | "medium" | "long" + var location: String? // "indoor" | "outdoor" | "both" + var imageUrl: String? +} + +struct DateSwipe: Codable, Identifiable, Sendable { + let id: String + var dateIdeaId: String + var userId: String + var action: String // "love" | "pass" + var swipedAt: Date +} + +struct DateMatch: Codable, Identifiable, Sendable { + let id: String + var coupleId: String + var dateIdeaId: String + var matchedAt: Date +} + +struct DatePlan: Codable, Identifiable, Sendable { + let id: String + var coupleId: String + var title: String + var description: String? + var date: Date? + var location: String? + var notes: String? + var createdBy: String + var createdAt: Date +} + +struct BucketListItem: Codable, Identifiable, Sendable { + let id: String + var title: String + var description: String? + var createdBy: String + var completed: Bool + var completedAt: Date? + var createdAt: Date +} + +// MARK: - Challenges & Capsules + +struct ConnectionChallenge: Codable, Identifiable, Sendable { + let id: String + var title: String + var description: String + var durationDays: Int + var tasks: [ChallengeTask] + var isPremium: Bool +} + +struct ChallengeTask: Codable, Sendable { + var day: Int + var title: String + var description: String +} + +struct ChallengeState: Codable, Sendable { + var challengeId: String + var currentDay: Int + var completedDays: [Int] + var startedAt: Date + var status: String // "active" | "completed" | "abandoned" +} + +struct MemoryCapsule: Codable, Identifiable, Sendable { + let id: String + var title: String + var content: String? + var imageUrls: [String]? + var status: String // "sealed" | "unlocked" + var unlockAt: Date + var createdBy: String + var createdAt: Date +} + +struct TimeCapsule: Codable, Identifiable, Sendable { + let id: String + var title: String + var message: String + var status: String + var unlockAt: Date + var createdBy: String + var createdAt: Date +} + +// MARK: - Streaks + +enum StreakType: Sendable { + case couple(count: Int, lastActiveDate: Date?, includesToday: Bool) + case personal(count: Int, lastActiveDate: Date?, includesToday: Bool) + case weeklyRhythm(count: Int, lastActiveDate: Date?, includesToday: Bool) +} + +struct StreakResult: Sendable { + let coupleStreak: StreakType + let personalStreak: StreakType + let weeklyRhythm: StreakType + let milestoneCopy: String? + let canRepair: Bool + let repairDueDate: Date? +} + +// MARK: - Game Types + +enum GameType: String, Sendable { + case wheel = "wheel" + case thisOrThat = "this_or_that" + case howWell = "how_well" + case desireSync = "desire_sync" + case connectionChallenges = "connection_challenges" +} + +// MARK: - Local Answer Caching + +struct LocalAnswer: Codable, Sendable { + let questionId: String + let answer: String + let answeredAt: Date + var isRevealed: Bool +} + +// MARK: - Weekly Recap + +struct WeeklyRecap: Codable, Sendable { + var weekStart: Date + var streakCount: Int + var answersShared: Int + var gamesPlayed: Int + var badgesEarned: [String] +} + +// MARK: - Answer Model + +struct Answer: Codable, Identifiable, Sendable { + let id: String + var coupleId: String + var questionId: String + var userId: String + var answerText: String + var createdAt: Date + var isRevealed: Bool +} \ No newline at end of file diff --git a/iphone/Closer/Models/FirestoreModels.swift b/iphone/Closer/Models/FirestoreModels.swift new file mode 100644 index 00000000..26d8e390 --- /dev/null +++ b/iphone/Closer/Models/FirestoreModels.swift @@ -0,0 +1,148 @@ +import Foundation + +struct User: Codable, Identifiable, Sendable { + 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: Date + var lastActiveAt: Date + + enum CodingKeys: String, CodingKey { + case id + case email + case displayName + case photoUrl + case sex + case partnerId + case coupleId + case plan + case createdAt + case lastActiveAt + } +} + +struct Couple: Codable, Identifiable, Sendable { + let id: String + var userIds: [String] + var inviteCode: String + var createdAt: Date + var currentQuestionId: String? + var streakCount: Int + var lastAnsweredAt: Date? + var activePackId: String? + + // E2EE fields (optional — MVP can skip) + var encryptionVersion: Int // 0=plaintext, 1=migrating, 2=strict + var wrappedCoupleKey: String? + var kdfSalt: String? + var kdfParams: String? + var encryptionMigrationUsers: [String: Bool]? + + enum CodingKeys: String, CodingKey { + case id + case userIds + case inviteCode + case createdAt + case currentQuestionId + case streakCount + case lastAnsweredAt + case activePackId + case encryptionVersion + case wrappedCoupleKey + case kdfSalt + case kdfParams + case encryptionMigrationUsers + } +} + +struct Invite: Codable, Identifiable, Sendable { + let id: String // = document ID (6-char code) + var code: String + var inviterUserId: String + var inviteeEmail: String? + var coupleId: String? + var status: String // "pending" | "accepted" | "expired" + var createdAt: Date + var expiresAt: Date + var acceptedAt: Date? + var acceptedByUserId: String? + + // E2EE + var wrappedCoupleKey: String? + var kdfSalt: String? + var kdfParams: String? + + enum CodingKeys: String, CodingKey { + case id + case code + case inviterUserId + case inviteeEmail + case coupleId + case status + case createdAt + case expiresAt + case acceptedAt + case acceptedByUserId + case wrappedCoupleKey + case kdfSalt + case kdfParams + } +} + +struct Entitlement: Codable, Identifiable, Sendable { + let id: String + var userId: String + var source: String + var productId: String + var isActive: Bool + var expiresAt: Date? + var updatedAt: Date + + enum CodingKeys: String, CodingKey { + case id + case userId + case source + case productId + case isActive + case expiresAt + case updatedAt + } +} + +struct DailyQuestion: Codable, Identifiable, Sendable { + @DocumentID var id: String? + var questionId: String + var date: String // YYYY-MM-DD + var assignedAt: Date + var expiresAt: Date +} + +struct DailyAnswer: Codable, Identifiable, Sendable { + @DocumentID var id: String? // = userId + var sealedAnswer: String? // "sealed:v1:{base64}" + var commitment: String? // "sha256:{urlsafe-base64}" + var questionType: String // "text" | "multiple_choice" | "scale" + var submittedAt: Date +} + +/// Timestamp wrapper for Firestore decoding +@propertyWrapper +struct DocumentID: Codable, Sendable { + var wrappedValue: String? + + init(wrappedValue: String?) { + self.wrappedValue = wrappedValue + } + + enum CodingKeys: CodingKey {} + + func encode(to encoder: Encoder) throws {} + init(from decoder: Decoder) throws { + wrappedValue = nil + } +} \ No newline at end of file diff --git a/iphone/Closer/Navigation/ContentView.swift b/iphone/Closer/Navigation/ContentView.swift new file mode 100644 index 00000000..8d65ffd9 --- /dev/null +++ b/iphone/Closer/Navigation/ContentView.swift @@ -0,0 +1,103 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var appState: AppState + + var body: some View { + Group { + switch appState.authState { + case .loading: + LoadingView(message: "Getting ready...") + case .unauthenticated: + OnboardingFlow() + case .authenticated(_, let isAnonymous): + if isAnonymous { + CreateProfileView() + } else if appState.currentUser?.coupleId == nil { + PairPromptView() + } else { + MainTabView() + } + } + } + } +} + +// MARK: - Main Tab View + +struct MainTabView: View { + @EnvironmentObject var appState: AppState + @State private var selectedTab: Tab = .home + + enum Tab: Hashable { + case home, dailyQuestion, play, questionPacks, settings + } + + var body: some View { + TabView(selection: $selectedTab) { + NavigationStack { + HomeView() + } + .tabItem { + Label("Home", systemImage: selectedTab == .home ? "house.fill" : "house") + } + .tag(Tab.home) + + NavigationStack { + DailyQuestionView() + } + .tabItem { + Label("Today", systemImage: selectedTab == .dailyQuestion ? "heart.fill" : "heart") + } + .tag(Tab.dailyQuestion) + + NavigationStack { + PlayHubView() + } + .tabItem { + Label("Play", systemImage: selectedTab == .play ? "play.fill" : "play") + } + .tag(Tab.play) + + NavigationStack { + QuestionPackLibraryView() + } + .tabItem { + Label("Packs", systemImage: selectedTab == .questionPacks ? "star.fill" : "star") + } + .tag(Tab.questionPacks) + + NavigationStack { + SettingsView() + } + .tabItem { + Label("Settings", systemImage: selectedTab == .settings ? "gearshape.fill" : "gearshape") + } + .tag(Tab.settings) + } + .tint(.closerPrimary) + } +} + +// MARK: - Onboarding Flow + +struct OnboardingFlow: View { + @State private var showLogin = false + @State private var showSignUp = false + + var body: some View { + NavigationStack { + OnboardingView(showLogin: $showLogin, showSignUp: $showSignUp) + .navigationDestination(isPresented: $showLogin) { + LoginView() + } + .navigationDestination(isPresented: $showSignUp) { + SignUpView() + } + } + } +} + +// MARK: - Import for Secret + +import Foundation \ No newline at end of file diff --git a/iphone/Closer/Onboarding/OnboardingViews.swift b/iphone/Closer/Onboarding/OnboardingViews.swift new file mode 100644 index 00000000..cb2e7d88 --- /dev/null +++ b/iphone/Closer/Onboarding/OnboardingViews.swift @@ -0,0 +1,516 @@ +import SwiftUI + +struct OnboardingView: View { + @Binding var showLogin: Bool + @Binding var showSignUp: Bool + @State private var currentPage = 0 + + let pages: [(icon: String, title: String, description: String)] = [ + ("heart.fill", "Connect Deeper", "Daily questions, games, and shared experiences designed to bring you closer together."), + ("lock.fill", "Private & Secure", "Your conversations are private. End-to-end encryption keeps your answers between you and your partner."), + ("sparkles", "Grow Together", "Build stronger habits, discover new things, and celebrate your journey as a couple.") + ] + + var body: some View { + VStack(spacing: 0) { + // Brand mark + Image(systemName: "heart.fill") + .font(.system(size: 60)) + .foregroundColor(.closerPrimary) + .padding(.top, CloserSpacing.xxxl) + + Text("Closer") + .font(CloserFont.largeTitle) + .foregroundColor(.closerPrimary) + .padding(.top, CloserSpacing.sm) + + Spacer() + + // Carousel + TabView(selection: $currentPage) { + ForEach(pages.indices, id: \.self) { index in + OnboardingPageView( + icon: pages[index].icon, + title: pages[index].title, + description: pages[index].description + ) + .tag(index) + } + } + .tabViewStyle(.page(indexDisplayMode: .always)) + .frame(height: 260) + + Spacer() + + // Action buttons + VStack(spacing: CloserSpacing.md) { + Button("Get Started") { + showSignUp = true + } + .buttonStyle(PrimaryButtonStyle()) + + HStack(spacing: 4) { + Text("Already have an account?") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + Button("Sign In") { + showLogin = true + } + .font(CloserFont.callout.weight(.semibold)) + .foregroundColor(.closerPrimary) + } + } + .closerPadding() + .padding(.bottom, CloserSpacing.xxxl) + } + .background(Color.closerBackground) + } +} + +struct OnboardingPageView: View { + let icon: String + let title: String + let description: String + + var body: some View { + VStack(spacing: CloserSpacing.xl) { + Image(systemName: icon) + .font(.system(size: 44)) + .foregroundColor(.closerPrimary) + + Text(title) + .font(CloserFont.title2) + .foregroundColor(.closerText) + + Text(description) + .font(CloserFont.body) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + .closerPadding() + } + } +} + +// MARK: - Login + +struct LoginView: View { + @EnvironmentObject var appState: AppState + @Environment(\.dismiss) private var dismiss + @State private var email = "" + @State private var password = "" + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showForgotPassword = false + + var body: some View { + ScrollView { + VStack(spacing: CloserSpacing.xxl) { + // Header + VStack(spacing: CloserSpacing.sm) { + Image(systemName: "heart.fill") + .font(.system(size: 44)) + .foregroundColor(.closerPrimary) + Text("Welcome Back") + .font(CloserFont.title1) + .foregroundColor(.closerText) + Text("Sign in to continue with your partner") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + } + .padding(.top, CloserSpacing.xxl) + + // Form + VStack(spacing: CloserSpacing.lg) { + VStack(alignment: .leading, spacing: 6) { + Text("Email").font(CloserFont.footnote).foregroundColor(.closerTextSecondary) + TextField("you@example.com", text: $email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .autocapitalization(.none) + .disableAutocorrection(true) + .padding() + .background(Color.closerSurface) + .cornerRadius(CloserRadius.medium) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Password").font(CloserFont.footnote).foregroundColor(.closerTextSecondary) + SecureField("Your password", text: $password) + .textContentType(.password) + .padding() + .background(Color.closerSurface) + .cornerRadius(CloserRadius.medium) + } + + if let error = errorMessage { + Text(error) + .font(CloserFont.caption) + .foregroundColor(.closerDanger) + } + + Button("Forgot Password?") { + showForgotPassword = true + } + .font(CloserFont.footnote) + .foregroundColor(.closerPrimary) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + // Actions + VStack(spacing: CloserSpacing.md) { + Button(action: signIn) { + if isLoading { + ProgressView().tint(.white) + } else { + Text("Sign In") + } + } + .buttonStyle(PrimaryButtonStyle(isDisabled: isLoading)) + .disabled(isLoading) + + Button(action: signInWithGoogle) { + HStack { + Image(systemName: "g.circle.fill") + .font(.title3) + Text("Continue with Google") + } + } + .buttonStyle(SecondaryButtonStyle()) + } + } + .closerPadding() + } + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(isPresented: $showForgotPassword) { + ForgotPasswordView() + } + } + + private func signIn() { + guard !email.isEmpty, !password.isEmpty else { + errorMessage = "Please enter email and password." + return + } + + isLoading = true + errorMessage = nil + + Task { + do { + _ = try await AuthService.shared.signInWithEmail(email, password: password) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + } + + private func signInWithGoogle() { + // Google Sign-In requires GoogleService-Info.plist setup + // Implementation uses GIDSignIn.sharedInstance.signIn(withPresenting:) + // This will be connected once the GoogleService-Info.plist is configured + } +} + +// MARK: - Sign Up + +struct SignUpView: View { + @EnvironmentObject var appState: AppState + @Environment(\.dismiss) private var dismiss + @State private var email = "" + @State private var password = "" + @State private var confirmPassword = "" + @State private var isLoading = false + @State private var errorMessage: String? + + var body: some View { + ScrollView { + VStack(spacing: CloserSpacing.xxl) { + VStack(spacing: CloserSpacing.sm) { + Image(systemName: "heart.fill") + .font(.system(size: 44)) + .foregroundColor(.closerPrimary) + Text("Create Account") + .font(CloserFont.title1) + .foregroundColor(.closerText) + Text("Start your journey together") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + } + .padding(.top, CloserSpacing.xxl) + + VStack(spacing: CloserSpacing.lg) { + VStack(alignment: .leading, spacing: 6) { + Text("Email").font(CloserFont.footnote).foregroundColor(.closerTextSecondary) + TextField("you@example.com", text: $email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .autocapitalization(.none) + .disableAutocorrection(true) + .padding() + .background(Color.closerSurface) + .cornerRadius(CloserRadius.medium) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Password").font(CloserFont.footnote).foregroundColor(.closerTextSecondary) + SecureField("At least 6 characters", text: $password) + .textContentType(.newPassword) + .padding() + .background(Color.closerSurface) + .cornerRadius(CloserRadius.medium) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Confirm Password").font(CloserFont.footnote).foregroundColor(.closerTextSecondary) + SecureField("Repeat password", text: $confirmPassword) + .textContentType(.newPassword) + .padding() + .background(Color.closerSurface) + .cornerRadius(CloserRadius.medium) + } + + if let error = errorMessage { + Text(error) + .font(CloserFont.caption) + .foregroundColor(.closerDanger) + } + } + + VStack(spacing: CloserSpacing.md) { + Button(action: signUp) { + if isLoading { + ProgressView().tint(.white) + } else { + Text("Create Account") + } + } + .buttonStyle(PrimaryButtonStyle(isDisabled: isLoading)) + .disabled(isLoading) + } + } + .closerPadding() + } + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + } + + private func signUp() { + guard !email.isEmpty, !password.isEmpty else { + errorMessage = "Please fill in all fields." + return + } + guard password == confirmPassword else { + errorMessage = "Passwords do not match." + return + } + guard password.count >= 6 else { + errorMessage = "Password must be at least 6 characters." + return + } + + isLoading = true + errorMessage = nil + + Task { + do { + _ = try await AuthService.shared.signUpWithEmail(email, password: password) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + } +} + +// MARK: - Forgot Password + +struct ForgotPasswordView: View { + @Environment(\.dismiss) private var dismiss + @State private var email = "" + @State private var isLoading = false + @State private var message: String? + @State private var errorMessage: String? + + var body: some View { + VStack(spacing: CloserSpacing.xxl) { + VStack(spacing: CloserSpacing.sm) { + Image(systemName: "lock.rotation") + .font(.system(size: 44)) + .foregroundColor(.closerPrimary) + Text("Reset Password") + .font(CloserFont.title1) + .foregroundColor(.closerText) + Text("Enter your email and we'll send you a reset link") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + } + .padding(.top, CloserSpacing.xxl) + + VStack(alignment: .leading, spacing: 6) { + Text("Email").font(CloserFont.footnote).foregroundColor(.closerTextSecondary) + TextField("you@example.com", text: $email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .autocapitalization(.none) + .disableAutocorrection(true) + .padding() + .background(Color.closerSurface) + .cornerRadius(CloserRadius.medium) + } + + if let msg = message { + Text(msg) + .font(CloserFont.callout) + .foregroundColor(.closerSuccess) + .multilineTextAlignment(.center) + } + if let error = errorMessage { + Text(error) + .font(CloserFont.caption) + .foregroundColor(.closerDanger) + } + + Button(action: sendResetEmail) { + if isLoading { + ProgressView().tint(.white) + } else { + Text("Send Reset Link") + } + } + .buttonStyle(PrimaryButtonStyle(isDisabled: isLoading)) + .disabled(isLoading) + + Spacer() + } + .closerPadding() + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + } + + private func sendResetEmail() { + guard !email.isEmpty else { + errorMessage = "Please enter your email." + return + } + + isLoading = true + errorMessage = nil + message = nil + + Task { + do { + try await AuthService.shared.sendPasswordResetEmail(email) + message = "Reset link sent! Check your inbox." + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + } +} + +// MARK: - Create Profile + +struct CreateProfileView: View { + @EnvironmentObject var appState: AppState + @State private var displayName = "" + @State private var sex = "" + @State private var isLoading = false + @State private var errorMessage: String? + + let sexOptions = ["Male", "Female", "Non-binary", "Prefer not to say"] + + var body: some View { + ScrollView { + VStack(spacing: CloserSpacing.xxl) { + VStack(spacing: CloserSpacing.sm) { + Image(systemName: "person.circle.fill") + .font(.system(size: 64)) + .foregroundColor(.closerPrimary) + Text("Create Your Profile") + .font(CloserFont.title1) + .foregroundColor(.closerText) + Text("Let your partner know who you are") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + } + .padding(.top, CloserSpacing.xxl) + + VStack(spacing: CloserSpacing.lg) { + VStack(alignment: .leading, spacing: 6) { + Text("Display Name").font(CloserFont.footnote).foregroundColor(.closerTextSecondary) + TextField("Your name", text: $displayName) + .padding() + .background(Color.closerSurface) + .cornerRadius(CloserRadius.medium) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Sex (optional)").font(CloserFont.footnote).foregroundColor(.closerTextSecondary) + Picker("Select", selection: $sex) { + Text("Select...").tag("") + ForEach(sexOptions, id: \.self) { option in + Text(option).tag(option) + } + } + .pickerStyle(.menu) + .padding() + .background(Color.closerSurface) + .cornerRadius(CloserRadius.medium) + } + + if let error = errorMessage { + Text(error) + .font(CloserFont.caption) + .foregroundColor(.closerDanger) + } + } + + Button(action: saveProfile) { + if isLoading { + ProgressView().tint(.white) + } else { + Text("Continue") + } + } + .buttonStyle(PrimaryButtonStyle(isDisabled: isLoading || displayName.isEmpty)) + .disabled(isLoading || displayName.isEmpty) + } + .closerPadding() + } + .background(Color.closerBackground) + } + + private func saveProfile() { + guard !displayName.isEmpty else { return } + isLoading = true + + Task { + do { + let userId = try FirestoreService.shared.userId() + let user = User( + id: userId, + email: AuthService.shared.currentUserEmail ?? "", + displayName: displayName, + photoUrl: "", + sex: sex, + partnerId: nil, + coupleId: nil, + plan: "free", + createdAt: Date(), + lastActiveAt: Date() + ) + try await FirestoreService.shared.setDocument(user, at: FirestoreService.shared.userDocument(userId)) + await appState.refreshData() + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + } +} \ No newline at end of file diff --git a/iphone/Closer/Pairing/PairingViews.swift b/iphone/Closer/Pairing/PairingViews.swift new file mode 100644 index 00000000..c920128e --- /dev/null +++ b/iphone/Closer/Pairing/PairingViews.swift @@ -0,0 +1,466 @@ +import SwiftUI + +// MARK: - Pair Prompt + +struct PairPromptView: View { + @State private var showCreateInvite = false + @State private var showAcceptInvite = false + @State private var showEmailInvite = false + + var body: some View { + NavigationStack { + VStack(spacing: CloserSpacing.xxl) { + Spacer() + + Image(systemName: "link.circle.fill") + .font(.system(size: 72)) + .foregroundColor(.closerPrimary) + + Text("Connect with Your Partner") + .font(CloserFont.title1) + .foregroundColor(.closerText) + .multilineTextAlignment(.center) + + Text("Create an invite code for your partner to join, or enter their code to connect.") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + .closerPadding() + + VStack(spacing: CloserSpacing.lg) { + Button(action: { showCreateInvite = true }) { + Label("Create Invite Code", systemImage: "plus.circle.fill") + } + .buttonStyle(PrimaryButtonStyle()) + + Button(action: { showAcceptInvite = true }) { + Label("Enter Partner's Code", systemImage: "key.fill") + } + .buttonStyle(SecondaryButtonStyle()) + + Button(action: { showEmailInvite = true }) { + Label("Invite by Email", systemImage: "envelope.fill") + } + .buttonStyle(SecondaryButtonStyle()) + } + .closerPadding() + + Spacer() + } + .background(Color.closerBackground) + .navigationDestination(isPresented: $showCreateInvite) { + CreateInviteView() + } + .navigationDestination(isPresented: $showAcceptInvite) { + AcceptInviteView() + } + .navigationDestination(isPresented: $showEmailInvite) { + EmailInviteView() + } + } + } +} + +// MARK: - Create Invite + +struct CreateInviteView: View { + @EnvironmentObject var appState: AppState + @State private var inviteCode = "" + @State private var isLoading = false + @State private var errorMessage: String? + + var body: some View { + ScrollView { + VStack(spacing: CloserSpacing.xxl) { + VStack(spacing: CloserSpacing.sm) { + Image(systemName: "square.and.arrow.up.fill") + .font(.system(size: 44)) + .foregroundColor(.closerPrimary) + Text("Share Your Code") + .font(CloserFont.title1) + .foregroundColor(.closerText) + Text("Share this code with your partner so they can connect with you") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + } + .padding(.top, CloserSpacing.xxl) + + if !inviteCode.isEmpty { + VStack(spacing: CloserSpacing.md) { + Text(inviteCode) + .font(.system(size: 40, weight: .bold, design: .monospaced)) + .foregroundColor(.closerPrimary) + .tracking(8) + .padding(CloserSpacing.xxl) + .background(Color.closerSurface) + .cornerRadius(CloserRadius.large) + + Button(action: copyCode) { + Label("Copy Code", systemImage: "doc.on.doc") + } + .buttonStyle(SecondaryButtonStyle()) + .frame(maxWidth: 200) + + Button(action: shareCode) { + Label("Share", systemImage: "square.and.arrow.up") + } + .buttonStyle(PrimaryButtonStyle()) + } + } else if isLoading { + ProgressView() + .tint(.closerPrimary) + } else { + Button("Generate Invite Code") { + generateInvite() + } + .buttonStyle(PrimaryButtonStyle()) + } + + if let error = errorMessage { + Text(error) + .font(CloserFont.caption) + .foregroundColor(.closerDanger) + } + + NavigationLink { + InviteConfirmView() + } label: { + Text("Your partner will see this after entering your code") + .font(CloserFont.footnote) + .foregroundColor(.closerTextSecondary) + } + } + .closerPadding() + } + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + } + + private func generateInvite() { + isLoading = true + errorMessage = nil + + Task { + do { + let userId = try FirestoreService.shared.userId() + let code = generateSixCharCode() + self.inviteCode = code + + let invite = Invite( + id: code, + code: code, + inviterUserId: userId, + inviteeEmail: nil, + coupleId: nil, + status: "pending", + createdAt: Date(), + expiresAt: Date().addingTimeInterval(24 * 60 * 60), + acceptedAt: nil, + acceptedByUserId: nil + ) + + let inviteRef = FirestoreService.shared.inviteDocument(code) + try await FirestoreService.shared.setDocument(invite, at: inviteRef, merge: false) + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + } + + private func copyCode() { + UIPasteboard.general.string = inviteCode + } + + private func shareCode() { + let av = UIActivityViewController(activityItems: [inviteCode], applicationActivities: nil) + if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = scene.windows.first, + let root = window.rootViewController { + root.present(av, animated: true) + } + } + + private func generateSixCharCode() -> String { + let chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // Avoid ambiguous 0/O, 1/I + return String((0..<6).map { _ in chars.randomElement()! }) + } +} + +// MARK: - Accept Invite + +struct AcceptInviteView: View { + @EnvironmentObject var appState: AppState + @State private var code = "" + @State private var isLoading = false + @State private var errorMessage: String? + + var body: some View { + ScrollView { + VStack(spacing: CloserSpacing.xxl) { + VStack(spacing: CloserSpacing.sm) { + Image(systemName: "key.fill") + .font(.system(size: 44)) + .foregroundColor(.closerPrimary) + Text("Enter Invite Code") + .font(CloserFont.title1) + .foregroundColor(.closerText) + Text("Ask your partner for their invite code and enter it below") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + } + .padding(.top, CloserSpacing.xxl) + + VStack(spacing: CloserSpacing.md) { + TextField("XXXXXX", text: $code) + .font(.system(size: 32, weight: .bold, design: .monospaced)) + .multilineTextAlignment(.center) + .tracking(8) + .autocapitalization(.allCharacters) + .disableAutocorrection(true) + .padding() + .background(Color.closerSurface) + .cornerRadius(CloserRadius.large) + .onChange(of: code) { oldValue, newValue in + if newValue.count > 6 { + code = String(newValue.prefix(6)) + } + } + + if let error = errorMessage { + Text(error) + .font(CloserFont.caption) + .foregroundColor(.closerDanger) + } + + Button(action: acceptInvite) { + if isLoading { + ProgressView().tint(.white) + } else { + Text("Connect") + } + } + .buttonStyle(PrimaryButtonStyle(isDisabled: isLoading || code.count != 6)) + .disabled(isLoading || code.count != 6) + } + } + .closerPadding() + } + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + } + + private func acceptInvite() { + guard code.count == 6 else { return } + isLoading = true + errorMessage = nil + + Task { + do { + let coupleId = try await FirestoreService.shared.acceptInviteCallable(code: code) + await appState.refreshData() + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + } +} + +// MARK: - Email Invite + +struct EmailInviteView: View { + @EnvironmentObject var appState: AppState + @State private var email = "" + @State private var isLoading = false + @State private var errorMessage: String? + @State private var successMessage: String? + + var body: some View { + ScrollView { + VStack(spacing: CloserSpacing.xxl) { + VStack(spacing: CloserSpacing.sm) { + Image(systemName: "envelope.fill") + .font(.system(size: 44)) + .foregroundColor(.closerPrimary) + Text("Invite by Email") + .font(CloserFont.title1) + .foregroundColor(.closerText) + Text("Send an invitation to your partner's email") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + } + .padding(.top, CloserSpacing.xxl) + + VStack(spacing: CloserSpacing.md) { + TextField("partner@example.com", text: $email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .autocapitalization(.none) + .disableAutocorrection(true) + .padding() + .background(Color.closerSurface) + .cornerRadius(CloserRadius.medium) + + if let success = successMessage { + Text(success) + .font(CloserFont.callout) + .foregroundColor(.closerSuccess) + } + if let error = errorMessage { + Text(error) + .font(CloserFont.caption) + .foregroundColor(.closerDanger) + } + + Button(action: sendInvite) { + if isLoading { + ProgressView().tint(.white) + } else { + Text("Send Invitation") + } + } + .buttonStyle(PrimaryButtonStyle(isDisabled: isLoading || email.isEmpty)) + .disabled(isLoading || email.isEmpty) + } + } + .closerPadding() + } + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + } + + private func sendInvite() { + // Email invite sends via Cloud Function or mail service + // For MVP, generate invite code and share system share sheet + guard !email.isEmpty else { return } + isLoading = true + errorMessage = nil + + Task { + do { + let userId = try FirestoreService.shared.userId() + let code = generateSixCharCode() + + let invite = Invite( + id: code, + code: code, + inviterUserId: userId, + inviteeEmail: email, + coupleId: nil, + status: "pending", + createdAt: Date(), + expiresAt: Date().addingTimeInterval(24 * 60 * 60), + acceptedAt: nil, + acceptedByUserId: nil + ) + + try await FirestoreService.shared.setDocument(invite, at: FirestoreService.shared.inviteDocument(code), merge: false) + successMessage = "Invitation sent! Share this code: \(code)" + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + } + + private func generateSixCharCode() -> String { + let chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + return String((0..<6).map { _ in chars.randomElement()! }) + } +} + +// MARK: - Invite Confirm + +struct InviteConfirmView: View { + @EnvironmentObject var appState: AppState + + var body: some View { + VStack(spacing: CloserSpacing.xxl) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 72)) + .foregroundColor(.closerSuccess) + + Text("Connected!") + .font(CloserFont.title1) + .foregroundColor(.closerText) + + Text("You and your partner are now connected. Start exploring questions and games together.") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + + Button("Let's Go!") { + Task { await appState.refreshData() } + } + .buttonStyle(PrimaryButtonStyle()) + } + .closerPadding() + .background(Color.closerBackground) + .navigationBarBackButtonHidden() + } +} + +// MARK: - Recovery + +struct RecoveryView: View { + var body: some View { + VStack(spacing: CloserSpacing.xxl) { + Image(systemName: "key.icloud.fill") + .font(.system(size: 48)) + .foregroundColor(.closerPrimary) + + Text("Unlock Answers") + .font(CloserFont.title1) + .foregroundColor(.closerText) + + Text("Enter your recovery phrase to restore access to encrypted answers. This is a 12-word phrase generated when E2EE was first enabled.") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + + Text("Recovery phrase setup is available when E2EE is fully implemented.") + .font(CloserFont.footnote) + .foregroundColor(.closerTextSecondary) + .italic() + + Spacer() + } + .closerPadding() + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Encryption Upgrade + +struct EncryptionUpgradeView: View { + var body: some View { + VStack(spacing: CloserSpacing.xxl) { + Image(systemName: "lock.shield.fill") + .font(.system(size: 48)) + .foregroundColor(.closerPrimary) + + Text("Secure Your Answers") + .font(CloserFont.title1) + .foregroundColor(.closerText) + + Text("End-to-end encryption ensures your answers are only visible to you and your partner.") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + + Text("E2EE upgrade is available when the full encryption layer is implemented.") + .font(CloserFont.footnote) + .foregroundColor(.closerTextSecondary) + .italic() + + Spacer() + } + .closerPadding() + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + } +} \ No newline at end of file diff --git a/iphone/Closer/Play/PlayViews.swift b/iphone/Closer/Play/PlayViews.swift new file mode 100644 index 00000000..935d54f9 --- /dev/null +++ b/iphone/Closer/Play/PlayViews.swift @@ -0,0 +1,735 @@ +import SwiftUI + +// MARK: - Play Hub + +struct PlayHubView: View { + @EnvironmentObject var appState: AppState + @State private var isPremium = false + @State private var showPaywall = false + + let games: [(icon: String, title: String, description: String, color: Color, isPremium: Bool, gameType: GameType)] = [ + ("dice.fill", "Spin the Wheel", "Let fate decide your next adventure", .closerPrimary, false, .wheel), + ("hand.raised.fill", "This or That", "Discover each other's preferences", .closerSecondary, false, .thisOrThat), + ("person.fill.questionmark", "How Well Do You Know Me", "Test your knowledge of each other", .categoryCommunication, false, .howWell), + ("sparkles", "Desire Sync", "Align your desires and dreams", .closerSecondary, true, .desireSync), + ("mountain.2.fill", "Connection Challenges", "Multi-day challenges for couples", .closerGold, true, .connectionChallenges), + ("clock.fill", "Memory Lane", "Revisit your time capsules", .closerPrimary, true, .memoryLane), + ] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: CloserSpacing.xl) { + Text("Play Together") + .font(CloserFont.title1) + .foregroundColor(.closerText) + .closerPadding() + + LazyVGrid(columns: [GridItem(.flexible())], spacing: CloserSpacing.md) { + ForEach(games, id: \.title) { game in + GameCard( + icon: game.icon, + title: game.title, + description: game.description, + color: game.color, + isPremium: game.isPremium, + isUnlocked: !game.isPremium || isPremium, + gameType: game.gameType + ) + } + } + .closerPadding() + + // Game History + NavigationLink { + GameHistoryView() + } label: { + HStack { + Image(systemName: "clock.arrow.circlepath") + Text("Past Games") + Spacer() + Image(systemName: "chevron.right") + } + .font(CloserFont.body) + .foregroundColor(.closerText) + .padding() + .closerCard() + } + .buttonStyle(.plain) + .closerPadding() + } + .padding(.vertical) + } + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showPaywall) { + PaywallView() + } + .task { + isPremium = await DefaultEntitlementChecker().hasPremium() + } + } +} + +// MARK: - Game Card + +struct GameCard: View { + let icon: String + let title: String + let description: String + let color: Color + let isPremium: Bool + let isUnlocked: Bool + let gameType: GameType + @State private var showGame = false + + var body: some View { + Button(action: handleTap) { + HStack(spacing: CloserSpacing.lg) { + // Icon + ZStack { + RoundedRectangle(cornerRadius: CloserRadius.medium) + .fill(color.opacity(0.15)) + .frame(width: 60, height: 60) + Image(systemName: icon) + .font(.title2) + .foregroundColor(color) + } + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(title) + .font(CloserFont.headline) + .foregroundColor(.closerText) + if isPremium { + PremiumBadge() + } + } + Text(description) + .font(CloserFont.caption) + .foregroundColor(.closerTextSecondary) + .lineLimit(2) + } + + Spacer() + + if !isUnlocked { + Image(systemName: "lock.fill") + .foregroundColor(.closerTextSecondary) + } + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.closerDivider) + } + .padding(CloserSpacing.md) + .closerCard() + } + .buttonStyle(.plain) + .navigationDestination(isPresented: $showGame) { + destinationView(for: gameType) + } + } + + private func handleTap() { + guard isUnlocked else { return } + showGame = true + } + + @ViewBuilder + private func destinationView(for type: GameType) -> some View { + switch type { + case .wheel: + CategoryPickerView() + case .thisOrThat: + ThisOrThatView() + case .howWell: + HowWellView() + case .desireSync: + DesireSyncView() + case .connectionChallenges: + ConnectionChallengesView() + } + } +} + +// MARK: - Game History + +struct GameHistoryView: View { + @State private var sessions: [QuestionSession] = [] + @State private var isLoading = true + + var body: some View { + List { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + } else if sessions.isEmpty { + EmptyStateView( + icon: "clock.arrow.circlepath", + title: "No Games Yet", + message: "Your game history will appear here once you start playing." + ) + .listRowBackground(Color.clear) + } else { + ForEach(sessions) { session in + NavigationLink { + destinationForReplay(session.gameType, sessionId: session.id) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(session.gameType.replacing("_", with: " ").capitalized) + .font(CloserFont.body) + .foregroundColor(.closerText) + Text(session.startedAt, style: .date) + .font(CloserFont.caption) + .foregroundColor(.closerTextSecondary) + } + .padding(.vertical, 4) + } + } + } + } + .listStyle(.insetGrouped) + .background(Color.closerBackground) + .navigationTitle("Game History") + .navigationBarTitleDisplayMode(.inline) + .task { + try? await Task.sleep(nanoseconds: 500_000_000) + isLoading = false + } + } + + @ViewBuilder + private func destinationForReplay(_ gameType: String, sessionId: String) -> some View { + switch gameType { + case "this_or_that": ThisOrThatReplayView(sessionId: sessionId) + case "how_well": HowWellReplayView(sessionId: sessionId) + case "desire_sync": DesireSyncReplayView(sessionId: sessionId) + default: Text("Replay not available") + } + } +} + +// MARK: - This or That + +struct ThisOrThatView: View { + @State private var currentPair = 0 + @State private var choices: [String] = [] + @State private var showResults = false + + let pairs = [ + ("Beach vacation", "Mountain retreat"), + ("Dinner out", "Cooking together"), + ("Movie night", "Board game night"), + ("Early bird", "Night owl"), + ("Cats", "Dogs"), + ("Coffee", "Tea"), + ("Summer", "Winter"), + ("City life", "Country life"), + ] + + var body: some View { + VStack(spacing: CloserSpacing.xxl) { + if showResults { + // Results view + VStack(spacing: CloserSpacing.lg) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 64)) + .foregroundColor(.closerSuccess) + Text("All Done!") + .font(CloserFont.title1) + Text("Your choices are recorded. See how they match with your partner when they play too.") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + Button("Play Again") { + currentPair = 0 + choices = [] + showResults = false + } + .buttonStyle(PrimaryButtonStyle()) + } + } else { + // Progress + Text("\(currentPair + 1) of \(pairs.count)") + .font(CloserFont.subheadline) + .foregroundColor(.closerTextSecondary) + + ProgressView(value: Double(currentPair + 1), total: Double(pairs.count)) + .tint(.closerPrimary) + + // Current pair + VStack(spacing: CloserSpacing.lg) { + Text("Would you rather...") + .font(CloserFont.title3) + .foregroundColor(.closerText) + + ForEach(pairs[currentPair].0, pairs[currentPair].1, id: \.self) { option in + Button(action: { choose(option) }) { + Text(option) + .font(CloserFont.body) + .foregroundColor(.closerText) + .frame(maxWidth: .infinity) + .padding() + .background(Color.closerSurface) + .cornerRadius(CloserRadius.large) + .overlay(RoundedRectangle(cornerRadius: CloserRadius.large).stroke(Color.closerDivider)) + } + } + + Text("or") + .font(CloserFont.body) + .foregroundColor(.closerTextSecondary) + + ForEach(pairs[currentPair].1, pairs[currentPair].0, id: \.self) { option in + Button(action: { choose(option) }) { + Text(option) + .font(CloserFont.body) + .foregroundColor(.closerText) + .frame(maxWidth: .infinity) + .padding() + .background(Color.closerSurface) + .cornerRadius(CloserRadius.large) + .overlay(RoundedRectangle(cornerRadius: CloserRadius.large).stroke(Color.closerDivider)) + } + } + } + } + } + .closerPadding() + .background(Color.closerBackground) + .navigationTitle("This or That") + .navigationBarTitleDisplayMode(.inline) + } + + private func choose(_ option: String) { + choices.append(option) + if currentPair < pairs.count - 1 { + withAnimation { + currentPair += 1 + } + } else { + withAnimation { + showResults = true + } + } + } +} + +// MARK: - How Well Do You Know Me + +struct HowWellView: View { + @State private var currentQuestion = 0 + @State private var score = 0 + @State private var showResults = false + @State private var selectedAnswer: String? + + let questions: [(question: String, options: [String], correctIndex: Int)] = [ + ("What's my favorite color?", ["Blue", "Red", "Green", "Purple"], 0), + ("What's my go-to comfort food?", ["Pizza", "Ice cream", "Pasta", "Chocolate"], 1), + ("What would I do with a free day?", ["Read a book", "Go outside", "Watch movies", "Sleep in"], 2), + ("What's my dream travel destination?", ["Japan", "Italy", "New Zealand", "Greece"], 3), + ("Am I more introverted or extroverted?", ["Introverted", "Extroverted", "It depends", "Both equally"], 0), + ] + + var body: some View { + VStack(spacing: CloserSpacing.xxl) { + if showResults { + VStack(spacing: CloserSpacing.lg) { + Image(systemName: score == questions.count ? "star.fill" : "heart.fill") + .font(.system(size: 64)) + .foregroundColor(score == questions.count ? .closerGold : .closerPrimary) + Text("\(score) / \(questions.count)") + .font(CloserFont.title1) + Text(scoreMessage) + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + Button("Play Again") { + currentQuestion = 0 + score = 0 + showResults = false + } + .buttonStyle(PrimaryButtonStyle()) + } + } else { + Text("\(currentQuestion + 1) of \(questions.count)") + .font(CloserFont.subheadline) + .foregroundColor(.closerTextSecondary) + + ProgressView(value: Double(currentQuestion + 1), total: Double(questions.count)) + .tint(.closerPrimary) + + Text(questions[currentQuestion].question) + .font(CloserFont.title2) + .foregroundColor(.closerText) + .multilineTextAlignment(.center) + + VStack(spacing: CloserSpacing.md) { + ForEach(questions[currentQuestion].options.indices, id: \.self) { index in + Button(action: { selectAnswer(index) }) { + Text(questions[currentQuestion].options[index]) + .font(CloserFont.body) + .foregroundColor(.closerText) + .frame(maxWidth: .infinity) + .padding() + .background(selectedAnswer == questions[currentQuestion].options[index] ? Color.closerPrimary.opacity(0.1) : Color.closerSurface) + .cornerRadius(CloserRadius.large) + .overlay(RoundedRectangle(cornerRadius: CloserRadius.large).stroke( + selectedAnswer == questions[currentQuestion].options[index] ? Color.closerPrimary : Color.closerDivider + )) + } + } + } + } + } + .closerPadding() + .background(Color.closerBackground) + .navigationTitle("How Well Do You Know Me") + .navigationBarTitleDisplayMode(.inline) + } + + private func selectAnswer(_ index: Int) { + let correct = questions[currentQuestion].correctIndex + if index == correct { score += 1 } + selectedAnswer = questions[currentQuestion].options[index] + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + selectedAnswer = nil + if currentQuestion < questions.count - 1 { + withAnimation { currentQuestion += 1 } + } else { + withAnimation { showResults = true } + } + } + } + + private var scoreMessage: String { + switch score { + case 0...2: return "Time to learn more about each other!" + case 3...4: return "You know your partner pretty well!" + case 5: return "Perfect score! You really know each other!" + default: return "" + } + } +} + +// MARK: - Desire Sync + +struct DesireSyncView: View { + @State private var currentQuestion = 0 + @State private var preferences: [String: Int] = [:] + @State private var showResults = false + + let questions: [(question: String, item: String)] = [ + ("How important is regular date night?", "date_night"), + ("How important is daily check-in?", "daily_checkin"), + ("How important is physical intimacy?", "intimacy"), + ("How important is shared adventure?", "adventure"), + ("How important is quality time at home?", "home_time"), + ] + + var body: some View { + VStack(spacing: CloserSpacing.xxl) { + if showResults { + VStack(spacing: CloserSpacing.lg) { + Image(systemName: "sparkles") + .font(.system(size: 64)) + .foregroundColor(.closerPrimary) + Text("Preferences Recorded!") + .font(CloserFont.title1) + Text("Your responses are saved. Compare with your partner when they complete theirs.") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + + Button("View Comparison") { + // Navigate to comparison view + } + .buttonStyle(PrimaryButtonStyle()) + + Button("Play Again") { + currentQuestion = 0 + preferences = [:] + showResults = false + } + .buttonStyle(SecondaryButtonStyle()) + } + } else { + Text("\(currentQuestion + 1) of \(questions.count)") + .font(CloserFont.subheadline) + .foregroundColor(.closerTextSecondary) + + ProgressView(value: Double(currentQuestion + 1), total: Double(questions.count)) + .tint(.closerPrimary) + + Text(questions[currentQuestion].question) + .font(CloserFont.title2) + .foregroundColor(.closerText) + .multilineTextAlignment(.center) + + VStack(spacing: CloserSpacing.sm) { + ForEach(1...5, id: \.self) { value in + Button(action: { setPreference(value) }) { + HStack { + Text(desireLabel(value)) + .font(CloserFont.callout) + .foregroundColor(.closerText) + Spacer() + if preferences[questions[currentQuestion].item] == value { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.closerPrimary) + } + } + .padding() + .background(preferences[questions[currentQuestion].item] == value ? Color.closerPrimary.opacity(0.1) : Color.closerSurface) + .cornerRadius(CloserRadius.medium) + .overlay(RoundedRectangle(cornerRadius: CloserRadius.medium).stroke(Color.closerDivider)) + } + } + } + } + } + .closerPadding() + .background(Color.closerBackground) + .navigationTitle("Desire Sync") + .navigationBarTitleDisplayMode(.inline) + } + + private func setPreference(_ value: Int) { + preferences[questions[currentQuestion].item] = value + if currentQuestion < questions.count - 1 { + withAnimation { currentQuestion += 1 } + } else { + withAnimation { showResults = true } + } + } + + private func desireLabel(_ value: Int) -> String { + switch value { + case 1: return "Not important" + case 2: return "Slightly important" + case 3: return "Moderately important" + case 4: return "Very important" + case 5: return "Essential" + default: return "" + } + } +} + +// MARK: - Connection Challenges + +struct ConnectionChallengesView: View { + @State private var challenges: [ConnectionChallenge] = [] + @State private var isLoading = true + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: CloserSpacing.lg) { + Text("Connection Challenges") + .font(CloserFont.title1) + .foregroundColor(.closerText) + .closerPadding() + + Text("Multi-day programs designed to strengthen your bond") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .closerPadding() + + if isLoading { + LoadingView(message: "Loading challenges...") + } else if challenges.isEmpty { + EmptyStateView( + icon: "mountain.2", + title: "No Challenges Yet", + message: "Connection challenges are coming soon!" + ) + } else { + ForEach(challenges) { challenge in + ChallengeCard(challenge: challenge) + .closerPadding() + } + } + } + .padding(.vertical) + } + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + .task { + try? await Task.sleep(nanoseconds: 500_000_000) + isLoading = false + } + } +} + +struct ChallengeCard: View { + let challenge: ConnectionChallenge + + var body: some View { + VStack(alignment: .leading, spacing: CloserSpacing.sm) { + HStack { + Text(challenge.title) + .font(CloserFont.headline) + .foregroundColor(.closerText) + Spacer() + if challenge.isPremium { + PremiumBadge() + } + } + Text(challenge.description) + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + HStack { + Image(systemName: "calendar") + Text("\(challenge.durationDays) days") + } + .font(CloserFont.caption) + .foregroundColor(.closerTextSecondary) + } + .padding(CloserSpacing.md) + .closerCard() + } +} + +// MARK: - Memory Lane + +struct MemoryLaneView: View { + @State private var capsules: [MemoryCapsule] = [] + @State private var isLoading = true + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: CloserSpacing.lg) { + Text("Memory Lane") + .font(CloserFont.title1) + .foregroundColor(.closerText) + .closerPadding() + + if isLoading { + LoadingView(message: "Loading memories...") + } else if capsules.isEmpty { + EmptyStateView( + icon: "clock.fill", + title: "No Memories Yet", + message: "Create time capsules to unlock memories in the future." + ) + } else { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: CloserSpacing.md) { + ForEach(capsules) { capsule in + CapsuleCard(capsule: capsule) + } + } + .closerPadding() + } + } + .padding(.vertical) + } + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + .task { + try? await Task.sleep(nanoseconds: 500_000_000) + isLoading = false + } + } +} + +struct CapsuleCard: View { + let capsule: MemoryCapsule + + var body: some View { + VStack(spacing: CloserSpacing.sm) { + Image(systemName: capsule.status == "unlocked" ? "envelope.open.fill" : "envelope.fill") + .font(.title) + .foregroundColor(capsule.status == "unlocked" ? .closerPrimary : .closerTextSecondary) + + Text(capsule.title) + .font(CloserFont.headline) + .foregroundColor(.closerText) + .lineLimit(2) + + if capsule.status == "sealed" { + Text("Unlocks \(capsule.unlockAt, style: .date)") + .font(CloserFont.caption) + .foregroundColor(.closerTextSecondary) + } + + if capsule.status == "unlocked" { + Text("Open") + .font(CloserFont.caption) + .foregroundColor(.closerSuccess) + } + } + .padding(CloserSpacing.md) + .frame(maxWidth: .infinity) + .closerCard() + } +} + +// MARK: - Waiting for Partner + +struct WaitingForPartnerView: View { + let gameName: String + + var body: some View { + VStack(spacing: CloserSpacing.xxl) { + Image(systemName: "hourglass") + .font(.system(size: 64)) + .foregroundColor(.closerPrimary) + + Text("Waiting for \(gameName)") + .font(CloserFont.title2) + .foregroundColor(.closerText) + + Text("Your partner hasn't finished this activity yet. Results will appear here once they do.") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + + ProgressView() + .tint(.closerPrimary) + .scaleEffect(1.5) + } + .closerPadding() + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Replay Views + +struct ThisOrThatReplayView: View { + let sessionId: String + + var body: some View { + WaitingForPartnerView(gameName: "This or That") + .navigationTitle("Results") + .navigationBarTitleDisplayMode(.inline) + } +} + +struct HowWellReplayView: View { + let sessionId: String + + var body: some View { + WaitingForPartnerView(gameName: "How Well Do You Know Me") + .navigationTitle("Results") + .navigationBarTitleDisplayMode(.inline) + } +} + +struct DesireSyncReplayView: View { + let sessionId: String + + var body: some View { + WaitingForPartnerView(gameName: "Desire Sync") + .navigationTitle("Results") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Helper Extensions + +extension String { + func replacing(_ target: String, with replacement: String) -> String { + replacingOccurrences(of: target, with: replacement) + } +} \ No newline at end of file diff --git a/iphone/Closer/Questions/QuestionViews.swift b/iphone/Closer/Questions/QuestionViews.swift new file mode 100644 index 00000000..e95347df --- /dev/null +++ b/iphone/Closer/Questions/QuestionViews.swift @@ -0,0 +1,574 @@ +import SwiftUI + +// MARK: - Daily Question + +struct DailyQuestionView: View { + @EnvironmentObject var appState: AppState + @State private var question: Question? + @State private var isLoading = true + @State private var hasAnswered = false + @State private var showReveal = false + + var body: some View { + ScrollView { + VStack(spacing: CloserSpacing.xxl) { + if isLoading { + LoadingView(message: "Loading today's question...") + .padding(.top, CloserSpacing.xxxl) + } else if let question = question { + // Question card + VStack(spacing: CloserSpacing.lg) { + Text("Today's Question") + .font(CloserFont.subheadline) + .foregroundColor(.closerTextSecondary) + + Text(question.text) + .font(CloserFont.title2) + .foregroundColor(.closerText) + .multilineTextAlignment(.center) + .closerPadding() + + if hasAnswered { + // Awaiting partner + VStack(spacing: CloserSpacing.md) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 48)) + .foregroundColor(.closerSuccess) + Text("You've answered!") + .font(CloserFont.title3) + .foregroundColor(.closerSuccess) + Text("Waiting for your partner to answer...") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + + Button("Send a gentle reminder") { + Task { + try? await FirestoreService.shared.sendGentleReminderCallable() + } + } + .font(CloserFont.footnote) + .foregroundColor(.closerPrimary) + } + } else { + // Answer options + QuestionAnswerView(question: question, onAnswered: { + withAnimation { hasAnswered = true } + }) + } + } + .padding(CloserSpacing.xl) + .closerCard() + .closerPadding() + + // Partner status + if hasAnswered { + PartnerStatusRow( + displayName: appState.currentUser?.displayName ?? "Partner", + answered: false, + lastActive: nil + ) + .closerPadding() + } + + // Reveal button if partner has answered + if hasAnswered { + Button("Reveal Partner's Answer") { + showReveal = true + } + .buttonStyle(PrimaryButtonStyle()) + .closerPadding() + } + } else { + EmptyStateView( + icon: "questionmark.bubble", + title: "No Question Today", + message: "Check back later for today's question.", + action: (title: "Refresh", handler: { Task { await loadQuestion() } }) + ) + } + } + } + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text("Daily Question") + .font(CloserFont.headline) + .foregroundColor(.closerText) + } + } + .navigationDestination(isPresented: $showReveal) { + AnswerRevealView(questionId: question?.id ?? "") + } + .task { + await loadQuestion() + } + } + + private func loadQuestion() async { + isLoading = true + defer { isLoading = false } + + // Fetch daily question from Firestore + guard let coupleId = appState.currentCouple?.id else { return } + let today = dateString() + + do { + let dq: DailyQuestion? = try await FirestoreService.shared.getDocument( + at: FirestoreService.shared.dailyQuestionRef(coupleId: coupleId, date: today) + ) + + if let questionId = dq?.questionId { + // Fetch question from local bundle or Firestore + self.question = bundledQuestions.first { $0.id == questionId } + // Check if user already answered + if let userId = AuthService.shared.currentUserId { + let answer: DailyAnswer? = try await FirestoreService.shared.getDocument( + at: FirestoreService.shared.dailyAnswerRef(coupleId: coupleId, date: today, userId: userId) + ) + hasAnswered = answer != nil && answer?.submittedAt.timeIntervalSince1970 ?? 0 > 0 + } + } + } catch { + // Fall back to bundled question + self.question = bundledQuestions.randomElement() + } + } + + private func dateString() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: Date()) + } +} + +// MARK: - Question Answer + +struct QuestionAnswerView: View { + let question: Question + let onAnswered: () -> Void + @State private var textAnswer = "" + @State private var selectedOptions: Set = [] + @State private var scaleValue: Double = 5 + @State private var isSubmitting = false + + var body: some View { + VStack(spacing: CloserSpacing.lg) { + switch question.type { + case "text": + TextEditor(text: $textAnswer) + .frame(minHeight: 120) + .padding(8) + .background(Color.closerBackground) + .cornerRadius(CloserRadius.medium) + .overlay( + RoundedRectangle(cornerRadius: CloserRadius.medium) + .stroke(Color.closerDivider) + ) + + Button(action: submitAnswer) { + if isSubmitting { + ProgressView().tint(.white) + } else { + Text("Submit Answer") + } + } + .buttonStyle(PrimaryButtonStyle(isDisabled: isSubmitting || textAnswer.isEmpty)) + .disabled(isSubmitting || textAnswer.isEmpty) + + case "multiple_choice": + if let options = question.options { + ForEach(options, id: \.self) { option in + Button(action: { + selectedOptions = [option] + }) { + HStack { + Image(systemName: selectedOptions.contains(option) ? "circle.fill" : "circle") + .foregroundColor(.closerPrimary) + Text(option) + .foregroundColor(.closerText) + Spacer() + } + .padding() + .background(selectedOptions.contains(option) ? Color.closerPrimary.opacity(0.1) : Color.closerBackground) + .cornerRadius(CloserRadius.medium) + .overlay( + RoundedRectangle(cornerRadius: CloserRadius.medium) + .stroke(selectedOptions.contains(option) ? Color.closerPrimary : Color.closerDivider) + ) + } + } + + Button(action: submitAnswer) { + if isSubmitting { + ProgressView().tint(.white) + } else { + Text("Submit Answer") + } + } + .buttonStyle(PrimaryButtonStyle(isDisabled: isSubmitting || selectedOptions.isEmpty)) + .disabled(isSubmitting || selectedOptions.isEmpty) + } + + case "scale": + VStack(spacing: CloserSpacing.sm) { + Text("\(Int(scaleValue))") + .font(CloserFont.largeTitle) + .foregroundColor(.closerPrimary) + + Slider(value: $scaleValue, in: 1...10, step: 1) + .tint(.closerPrimary) + + HStack { + Text("1").font(CloserFont.caption).foregroundColor(.closerTextSecondary) + Spacer() + Text("10").font(CloserFont.caption).foregroundColor(.closerTextSecondary) + } + } + + Button(action: submitAnswer) { + if isSubmitting { + ProgressView().tint(.white) + } else { + Text("Submit Answer") + } + } + .buttonStyle(PrimaryButtonStyle(isDisabled: isSubmitting)) + .disabled(isSubmitting) + + default: + Text("Unsupported question type") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + } + } + } + + private func submitAnswer() { + isSubmitting = true + Task { + try? await Task.sleep(nanoseconds: 500_000_000) // Simulate submit + isSubmitting = false + onAnswered() + } + } +} + +// MARK: - Answer Reveal + +struct AnswerRevealView: View { + let questionId: String + @EnvironmentObject var appState: AppState + @State private var partnerAnswer: String? + @State private var isLoading = true + + var body: some View { + VStack(spacing: CloserSpacing.xxl) { + if isLoading { + LoadingView(message: "Loading answer...") + } else if let answer = partnerAnswer { + Image(systemName: "heart.fill") + .font(.system(size: 64)) + .foregroundColor(.closerDanger) + + Text("Partner's Answer") + .font(CloserFont.title2) + .foregroundColor(.closerText) + + Text(answer) + .font(CloserFont.body) + .foregroundColor(.closerText) + .multilineTextAlignment(.center) + .padding(CloserSpacing.xl) + .closerCard() + + Text("This answer is end-to-end encrypted and can only be seen by you and your partner.") + .font(CloserFont.footnote) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + } else { + EmptyStateView( + icon: "eye.slash.fill", + title: "Not Yet Available", + message: "Your partner hasn't answered yet, or the answer hasn't been revealed." + ) + } + } + .closerPadding() + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + .task { + // Load partner's answer + try? await Task.sleep(nanoseconds: 800_000_000) + isLoading = false + } + } +} + +// MARK: - Answer History + +struct AnswerHistoryView: View { + @State private var answers: [Answer] = [] + @State private var isLoading = true + + var body: some View { + List { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + } else if answers.isEmpty { + EmptyStateView( + icon: "clock.arrow.circlepath", + title: "No Answers Yet", + message: "Your answer history will appear here." + ) + .listRowBackground(Color.clear) + } else { + ForEach(answers) { answer in + NavigationLink { + AnswerRevealView(questionId: answer.questionId) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(answer.answerText) + .font(CloserFont.body) + .foregroundColor(.closerText) + Text(answer.createdAt, style: .date) + .font(CloserFont.caption) + .foregroundColor(.closerTextSecondary) + } + .padding(.vertical, 4) + } + } + } + } + .listStyle(.insetGrouped) + .background(Color.closerBackground) + .navigationTitle("Answer History") + .navigationBarTitleDisplayMode(.inline) + .task { + // Load answers + try? await Task.sleep(nanoseconds: 500_000_000) + isLoading = false + } + } +} + +// MARK: - Question Pack Library + +struct QuestionPackLibraryView: View { + @State private var packs: [QuestionPack] = [] + @State private var isLoading = true + @State private var selectedPack: QuestionPack? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: CloserSpacing.lg) { + Text("Question Packs") + .font(CloserFont.title1) + .foregroundColor(.closerText) + .padding(.horizontal) + + if isLoading { + LoadingView(message: "Loading packs...") + } else if packs.isEmpty { + EmptyStateView( + icon: "star.slash", + title: "No Packs Yet", + message: "Question packs will appear here." + ) + } else { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: CloserSpacing.md) { + ForEach(packs) { pack in + NavigationLink { + QuestionCategoryView(categoryId: pack.id) + } label: { + PackCard(pack: pack) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + } + } + .padding(.vertical) + } + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + .task { + try? await Task.sleep(nanoseconds: 500_000_000) + packs = samplePacks + isLoading = false + } + } +} + +struct PackCard: View { + let pack: QuestionPack + + var body: some View { + VStack(alignment: .leading, spacing: CloserSpacing.sm) { + CategoryGlyph(name: pack.name, color: .closerPrimary) + + Text(pack.name) + .font(CloserFont.headline) + .foregroundColor(.closerText) + .lineLimit(2) + + Text(pack.description ?? "") + .font(CloserFont.caption) + .foregroundColor(.closerTextSecondary) + .lineLimit(2) + + if pack.isPremium { + PremiumBadge() + } + } + .padding(CloserSpacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .closerCard() + } +} + +// MARK: - Question Category + +struct QuestionCategoryView: View { + let categoryId: String + @State private var questions: [Question] = [] + @State private var showComposer = false + + var body: some View { + List { + if questions.isEmpty { + EmptyStateView( + icon: "questionmark.bubble", + title: "No Questions", + message: "This pack has no questions yet." + ) + .listRowBackground(Color.clear) + } else { + ForEach(questions) { question in + NavigationLink { + QuestionThreadView(coupleId: "", questionId: question.id) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(question.text) + .font(CloserFont.body) + .foregroundColor(.closerText) + Text(question.type.replacingOccurrences(of: "_", with: " ").capitalized) + .font(CloserFont.caption) + .foregroundColor(.closerTextSecondary) + } + .padding(.vertical, 4) + } + } + } + } + .listStyle(.insetGrouped) + .background(Color.closerBackground) + .navigationTitle("Questions") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: { showComposer = true }) { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showComposer) { + QuestionComposerView() + } + .task { + questions = sampleQuestions + } + } +} + +// MARK: - Question Composer + +struct QuestionComposerView: View { + @Environment(\.dismiss) private var dismiss + @State private var questionText = "" + @State private var selectedType = "text" + + var body: some View { + NavigationStack { + Form { + Section("Question") { + TextField("Write your question...", text: $questionText, axis: .vertical) + .lineLimit(3...6) + } + + Section("Type") { + Picker("Type", selection: $selectedType) { + Text("Text").tag("text") + Text("Multiple Choice").tag("multiple_choice") + Text("Scale").tag("scale") + } + } + } + .navigationTitle("New Question") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Send") { dismiss() } + .disabled(questionText.isEmpty) + } + } + } + } +} + +// MARK: - Question Thread + +struct QuestionThreadView: View { + let coupleId: String + let questionId: String + @State private var messages: [QuestionMessage] = [] + + var body: some View { + VStack { + if messages.isEmpty { + EmptyStateView( + icon: "bubble.left.and.bubble.right", + title: "No Messages Yet", + message: "Start a conversation about this question." + ) + } else { + List(messages) { message in + Text(message.text) + .font(CloserFont.body) + } + } + } + .background(Color.closerBackground) + .navigationTitle("Discussion") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Sample Data + +let bundledQuestions: [Question] = [ + Question(id: "q1", text: "What made you smile today?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: nil, packId: nil), + Question(id: "q2", text: "What's one thing you appreciate about your partner?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: nil, packId: nil), + Question(id: "q3", text: "How connected do you feel today?", type: "scale", options: nil, scaleMin: 1, scaleMax: 10, categoryId: nil, packId: nil), + Question(id: "q4", text: "What's your ideal weekend activity together?", type: "multiple_choice", options: ["Relax at home", "Outdoor adventure", "Date night out", "Try something new"], scaleMin: nil, scaleMax: nil, categoryId: nil, packId: nil), +] + +let sampleQuestions: [Question] = [ + Question(id: "s1", text: "What's a dream you'd like to pursue together?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: nil, packId: nil), + Question(id: "s2", text: "How do you feel loved most?", type: "multiple_choice", options: ["Words of affirmation", "Quality time", "Physical touch", "Acts of service", "Gifts"], scaleMin: nil, scaleMax: nil, categoryId: nil, packId: nil), + Question(id: "s3", text: "Rate your communication today", type: "scale", options: nil, scaleMin: 1, scaleMax: 10, categoryId: nil, packId: nil), + Question(id: "s4", text: "What's one new thing you want to try as a couple?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: nil, packId: nil), +] + +let samplePacks: [QuestionPack] = [ + QuestionPack(id: "p1", name: "Getting Closer", description: "Deepen your connection", categories: nil, isPremium: false), + QuestionPack(id: "p2", name: "Fun & Playful", description: "Lighthearted questions", categories: nil, isPremium: false), + QuestionPack(id: "p3", name: "Intimacy", description: "Build emotional intimacy", categories: nil, isPremium: true), + QuestionPack(id: "p4", name: "Future Together", description: "Plan your future", categories: nil, isPremium: true), +] \ No newline at end of file diff --git a/iphone/Closer/Services/FirestoreService.swift b/iphone/Closer/Services/FirestoreService.swift new file mode 100644 index 00000000..6278a5ce --- /dev/null +++ b/iphone/Closer/Services/FirestoreService.swift @@ -0,0 +1,199 @@ +import Foundation +import FirebaseFirestore +import FirebaseAuth +import FirebaseFunctions + +// MARK: - Firestore Service + +final class FirestoreService: @unchecked Sendable { + static let shared = FirestoreService() + + let db = Firestore.firestore() + let functions = Functions.functions() + + private init() {} + + // MARK: - Collection References + + func usersCollection() -> CollectionReference { + db.collection("users") + } + + func userDocument(_ userId: String) -> DocumentReference { + usersCollection().document(userId) + } + + func couplesCollection() -> CollectionReference { + db.collection("couples") + } + + func coupleDocument(_ coupleId: String) -> DocumentReference { + couplesCollection().document(coupleId) + } + + func invitesCollection() -> CollectionReference { + db.collection("invites") + } + + func inviteDocument(_ code: String) -> DocumentReference { + invitesCollection().document(code) + } + + // MARK: - Subcollections + + func dailyQuestionRef(coupleId: String, date: String) -> DocumentReference { + coupleDocument(coupleId) + .collection("daily_question") + .document(date) + } + + func dailyAnswerRef(coupleId: String, date: String, userId: String) -> DocumentReference { + dailyQuestionRef(coupleId: coupleId, date: date) + .collection("answers") + .document(userId) + } + + func questionThreadsRef(coupleId: String) -> CollectionReference { + coupleDocument(coupleId).collection("question_threads") + } + + func dateSwipesRef(coupleId: String) -> CollectionReference { + coupleDocument(coupleId).collection("date_swipes") + } + + func dateMatchesRef(coupleId: String) -> CollectionReference { + coupleDocument(coupleId).collection("date_matches") + } + + func bucketListRef(coupleId: String) -> CollectionReference { + coupleDocument(coupleId).collection("bucket_list") + } + + func capsulesRef(coupleId: String) -> CollectionReference { + coupleDocument(coupleId).collection("capsules") + } + + func sessionsRef(coupleId: String) -> CollectionReference { + coupleDocument(coupleId).collection("sessions") + } + + func entitlementDocument(_ userId: String) -> DocumentReference { + userDocument(userId) + .collection("entitlements") + .document("premium") + } + + func fcmTokensRef(_ userId: String) -> CollectionReference { + userDocument(userId).collection("fcmTokens") + } + + // MARK: - Helpers + + func userId() throws -> String { + guard let uid = Auth.auth().currentUser?.uid else { + throw FirestoreError.notAuthenticated + } + return uid + } + + func setDocument(_ value: T, at document: DocumentReference, merge: Bool = true) async throws { + if merge { + try await document.setData(value.asDictionary(), merge: true) + } else { + try await document.setData(value.asDictionary()) + } + } + + func getDocument(at document: DocumentReference) async throws -> T? { + let snapshot = try await document.getDocument() + guard snapshot.exists else { return nil } + return try snapshot.data(as: T.self) + } + + func getDocuments(in collection: CollectionReference) async throws -> [T] { + let snapshot = try await collection.getDocuments() + return try snapshot.documents.compactMap { try $0.data(as: T.self) } + } + + func queryDocuments( + in collection: CollectionReference, + where field: String, + isEqualTo value: Any + ) async throws -> [T] { + let snapshot = try await collection.whereField(field, isEqualTo: value).getDocuments() + return try snapshot.documents.compactMap { try $0.data(as: T.self) } + } +} + +// MARK: - Callable Functions + +extension FirestoreService { + func acceptInviteCallable(code: String, recoveryPhrase: String? = nil) async throws -> String { + var data: [String: Any] = ["code": code] + if let phrase = recoveryPhrase { + data["recoveryPhrase"] = phrase + } + let result = try await functions.httpsCallable("acceptInviteCallable").call(data) + guard let coupleId = (result.data as? [String: Any])?["coupleId"] as? String else { + throw FirestoreError.invalidResponse + } + return coupleId + } + + func leaveCoupleCallable() async throws { + let result = try await functions.httpsCallable("leaveCoupleCallable").call() + guard let success = (result.data as? [String: Any])?["success"] as? Bool, success else { + throw FirestoreError.invalidResponse + } + } + + func syncEntitlementCallable() async throws -> Entitlement { + let result = try await functions.httpsCallable("syncEntitlement").call() + guard let data = result.data as? [String: Any] else { + throw FirestoreError.invalidResponse + } + return Entitlement( + id: UUID().uuidString, + userId: try userId(), + source: "sync", + productId: "closer_premium", + isActive: data["premium"] as? Bool ?? false, + expiresAt: (data["expiresAt"] as? Timestamp)?.dateValue(), + updatedAt: Date() + ) + } + + func sendGentleReminderCallable() async throws { + try await functions.httpsCallable("sendGentleReminderCallable").call() + } +} + +// MARK: - Errors + +enum FirestoreError: LocalizedError { + case notAuthenticated + case invalidResponse + case documentNotFound + case permissionDenied + + var errorDescription: String? { + switch self { + case .notAuthenticated: return "User is not signed in." + case .invalidResponse: return "Invalid server response." + case .documentNotFound: return "Document not found." + case .permissionDenied: return "Permission denied." + } + } +} + +// MARK: - Encodable Helpers + +extension Encodable { + func asDictionary() throws -> [String: Any] { + let data = try JSONEncoder().encode(self) + guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw EncodingError.invalidValue(self, .init(codingPath: [], debugDescription: "Not a dictionary")) + } + return dict + } +} \ No newline at end of file diff --git a/iphone/Closer/Settings/SettingsViews.swift b/iphone/Closer/Settings/SettingsViews.swift new file mode 100644 index 00000000..80366ef0 --- /dev/null +++ b/iphone/Closer/Settings/SettingsViews.swift @@ -0,0 +1,915 @@ +import SwiftUI +import RevenueCat +import RevenueCatUI + +// MARK: - Settings + +struct SettingsView: View { + @EnvironmentObject var appState: AppState + @State private var showPaywall = false + @State private var showLogoutConfirm = false + @State private var showDeleteConfirm = false + @State private var isPairingActive = false + + var body: some View { + NavigationStack { + List { + // Profile section + Section { + VStack(spacing: CloserSpacing.sm) { + Circle() + .fill(Color.closerPrimary.opacity(0.2)) + .frame(width: 72, height: 72) + .overlay( + Text(initials) + .font(CloserFont.title1) + .foregroundColor(.closerPrimary) + ) + + Text(appState.currentUser?.displayName ?? "You") + .font(CloserFont.title3) + .foregroundColor(.closerText) + + if let email = appState.currentUser?.email { + Text(email) + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical) + } + + // Connection + Section("Connection") { + if let partner = appState.currentPartner { + HStack { + Circle() + .fill(Color.closerSuccess) + .frame(width: 12, height: 12) + Text("Connected with \(partner.displayName ?? "Partner")") + .font(CloserFont.body) + } + } else { + Button(action: { isPairingActive = true }) { + Label("Pair with Partner", systemImage: "link") + } + } + + NavigationLink { + EmailInviteView() + } label: { + Label("Invite Partner", systemImage: "envelope") + } + } + + // Account + Section("Account") { + if !isLoggedInAnonymously { + NavigationLink { + EditProfileView() + } label: { + Label("Edit Profile", systemImage: "person") + } + } + + Button("Upgrade to Premium") { + showPaywall = true + } + .disabled(isLoggedInAnonymously) + + if isLoggedInAnonymously { + NavigationLink { + SignUpView() + } label: { + Label("Create Account (Save Data)", systemImage: "lock") + } + } + } + + // Notifications + Section("Notifications") { + Toggle("Push Notifications", isOn: .constant(true)) + Toggle("Daily Question Reminders", isOn: .constant(true)) + Toggle("Partner Activity", isOn: .constant(true)) + Toggle("Gentle Reminders", isOn: .constant(true)) + + NavigationLink { + NavigationSettingsView() + } label: { + Label("Notification Settings", systemImage: "bell.badge") + } + } + + // Privacy + Section("Privacy") { + NavigationLink { + Text("Privacy Policy") + } label: { + Label("Privacy Policy", systemImage: "hand.raised") + } + + NavigationLink { + Text("Terms of Service") + } label: { + Label("Terms of Service", systemImage: "doc.text") + } + + NavigationLink { + DataExportView() + } label: { + Label("Export Data", systemImage: "square.and.arrow.up") + } + + Toggle("Share Anonymous Usage Data", isOn: .constant(true)) + } + + // Support + Section("Support") { + NavigationLink { + HelpCenterView() + } label: { + Label("Help Center", systemImage: "questionmark.circle") + } + + NavigationLink { + Text("Contact Us") + } label: { + Label("Contact Us", systemImage: "envelope") + } + + HStack { + Label("Version", systemImage: "info.circle") + Spacer() + Text(appVersion) + .font(CloserFont.footnote) + .foregroundColor(.closerTextSecondary) + } + } + + // Danger zone + Section { + Button(role: .destructive, action: { showLogoutConfirm = true }) { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + } + + Button(role: .destructive, action: { showDeleteConfirm = true }) { + Label("Delete Account", systemImage: "trash") + } + } + } + .listStyle(.insetGrouped) + .background(Color.closerBackground) + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .fullScreenCover(isPresented: $showPaywall) { + PaywallView() + } + .fullScreenCover(isPresented: $isPairingActive) { + CreateInviteView() + } + .alert("Sign Out", isPresented: $showLogoutConfirm) { + Button("Cancel", role: .cancel) {} + Button("Sign Out", role: .destructive) { + Task { await AuthService.shared.signOut() } + } + } message: { + Text("Are you sure you want to sign out? Your data will be saved.") + } + .alert("Delete Account", isPresented: $showDeleteConfirm) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + Task { await deleteAccount() } + } + } message: { + Text("This will permanently delete your account and all data. This action cannot be undone.") + } + } + } + + private var initials: String { + let name = appState.currentUser?.displayName ?? "U" + let parts = name.split(separator: " ") + let initials = parts.prefix(2).compactMap { $0.first.map(String.init) }.joined() + return initials.isEmpty ? "U" : initials.uppercased() + } + + private var isLoggedInAnonymously: Bool { + AuthService.shared.currentUser?.isAnonymous ?? true + } + + private var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" + } + + private func deleteAccount() async { + do { + try await FirestoreService.shared.deleteUserCallable() + await AuthService.shared.signOut() + } catch { + // Error handled upstream + } + } +} + +// MARK: - Edit Profile + +struct EditProfileView: View { + @State private var displayName = "" + @State private var bio = "" + @State private var isLoading = false + + var body: some View { + Form { + Section("Profile") { + TextField("Display Name", text: $displayName) + TextField("Bio (optional)", text: $bio, axis: .vertical) + .lineLimit(3) + } + + Section { + Button(action: saveProfile) { + if isLoading { + ProgressView() + } else { + Text("Save") + } + } + .disabled(displayName.isEmpty || isLoading) + .frame(maxWidth: .infinity) + } + } + .background(Color.closerBackground) + .navigationTitle("Edit Profile") + .navigationBarTitleDisplayMode(.inline) + .task { + displayName = AuthService.shared.currentUser?.displayName ?? "" + } + } + + private func saveProfile() { + isLoading = true + Task { + try? await FirestoreService.shared.updateUserCallable(displayName: displayName, bio: bio.isEmpty ? nil : bio) + isLoading = false + } + } +} + +// MARK: - Navigation Settings + +struct NavigationSettingsView: View { + @AppStorage("dailyReminderEnabled") private var dailyReminders = true + @AppStorage("reminderHour") private var reminderHour = 20 + @AppStorage("reminderMinute") private var reminderMinute = 0 + @AppStorage("quietHoursEnabled") private var quietHoursEnabled = false + @AppStorage("quietHourStart") private var quietHourStart = 22 + @AppStorage("quietHourEnd") private var quietHourEnd = 8 + + var body: some View { + Form { + Section("Daily Question") { + Toggle("Daily Reminder", isOn: $dailyReminders) + + if dailyReminders { + DatePicker("Reminder Time", + selection: Binding( + get: { + Calendar.current.date(from: DateComponents(hour: reminderHour, minute: reminderMinute)) ?? Date() + }, + set: { date in + let comps = Calendar.current.dateComponents([.hour, .minute], from: date) + reminderHour = comps.hour ?? 20 + reminderMinute = comps.minute ?? 0 + } + ), + displayedComponents: .hourAndMinute + ) + } + } + + Section("Quiet Hours") { + Toggle("Quiet Hours", isOn: $quietHoursEnabled) + + if quietHoursEnabled { + HStack { + Text("From") + Spacer() + Picker("Start", selection: $quietHourStart) { + ForEach(0..<24) { hour in + Text("\(hour):00").tag(hour) + } + } + .pickerStyle(.menu) + } + + HStack { + Text("To") + Spacer() + Picker("End", selection: $quietHourEnd) { + ForEach(0..<24) { hour in + Text("\(hour):00").tag(hour) + } + } + .pickerStyle(.menu) + } + } + } + + Section("Partner Activity") { + Toggle("Partner Activity Alerts", isOn: .constant(true)) + Toggle("When partner answers question", isOn: .constant(true)) + Toggle("When partner sends gentle reminder", isOn: .constant(true)) + Toggle("When new date match", isOn: .constant(true)) + } + } + .background(Color.closerBackground) + .navigationTitle("Notification Settings") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Data Export + +struct DataExportView: View { + @State private var isExporting = false + @State private var exportComplete = false + @State private var showError = false + + var body: some View { + VStack(spacing: CloserSpacing.xl) { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 64)) + .foregroundColor(.closerPrimary) + + Text("Export Your Data") + .font(CloserFont.title2) + .foregroundColor(.closerText) + + Text("Download a copy of all your Closer data including answers, memories, and preferences.") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + + if exportComplete { + HStack(spacing: CloserSpacing.sm) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.closerSuccess) + Text("Export completed — check your downloads") + .font(CloserFont.callout) + .foregroundColor(.closerSuccess) + } + .padding() + .background(Color.closerSuccess.opacity(0.1)) + .cornerRadius(CloserRadius.medium) + } + + Button(action: exportData) { + if isExporting { + ProgressView() + .tint(.white) + } else { + Text(exportComplete ? "Export Again" : "Export My Data") + } + } + .buttonStyle(PrimaryButtonStyle(isDisabled: isExporting)) + .disabled(isExporting) + .closerPadding() + + if exportComplete { + Button("Done") { + exportComplete = false + } + .font(CloserFont.callout) + .foregroundColor(.closerPrimary) + } + } + .closerPadding() + .background(Color.closerBackground) + .navigationTitle("Export Data") + .navigationBarTitleDisplayMode(.inline) + .alert("Export Failed", isPresented: $showError) { + Button("OK", role: .cancel) {} + } message: { + Text("Unable to export your data. Please try again later.") + } + } + + private func exportData() { + isExporting = true + Task { + do { + try await FirestoreService.shared.exportDataCallable() + await MainActor.run { + isExporting = false + exportComplete = true + } + } catch { + await MainActor.run { + isExporting = false + showError = true + } + } + } + } +} + +// MARK: - Help Center + +struct HelpCenterView: View { + var body: some View { + List { + Section("FAQs") { + NavigationLink("How does the daily question work?") { + DailyQuestionHelpView() + } + NavigationLink("What happens if I sign out?") { + SignOutHelpView() + } + NavigationLink("How does pairing work?") { + PairingHelpView() + } + NavigationLink("What is Premium?") { + PremiumHelpView() + } + NavigationLink("How does encryption work?") { + EncryptionHelpView() + } + } + + Section("Troubleshooting") { + NavigationLink("Notifications not working") { + GenericHelpView(title: "Notifications", body: "Make sure push notifications are enabled in your device Settings > Closer. If you've denied permission, go to Settings > Closer > Notifications and enable them.") + } + NavigationLink("Can't pair with partner") { + GenericHelpView(title: "Pairing Issues", body: "Confirm your partner has created an account. Check that your invite code is entered correctly (hypens are optional). If the code expired, generate a new one from Settings > Connection.") + } + NavigationLink("Restore purchases") { + GenericHelpView(title: "Restore Purchases", body: "Open Settings and tap 'Upgrade to Premium', then tap 'Restore Purchases'. If your subscription was purchased with a different Apple ID, sign in to that Apple ID and try again.") + } + } + } + .listStyle(.insetGrouped) + .background(Color.closerBackground) + .navigationTitle("Help Center") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Help Detail Views + +struct DailyQuestionHelpView: View { + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: CloserSpacing.md) { + Text("Every day, you and your partner receive the same question. Answer independently — your answer is private until both of you respond. Once both answers are in, you can reveal each other's answers together. It's a simple way to deepen your connection one day at a time.") + .font(CloserFont.body) + .foregroundColor(.closerText) + + Text("Tips:") + .font(CloserFont.headline) + .foregroundColor(.closerText) + .padding(.top) + + BulletPoint("Be honest — your partner sees only what you share") + BulletPoint("Set a daily reminder in Settings > Notifications") + BulletPoint("Send a gentle reminder if your partner hasn't answered yet") + BulletPoint("Past answers live in Answer History") + } + .closerPadding() + } + .background(Color.closerBackground) + .navigationTitle("Daily Questions") + .navigationBarTitleDisplayMode(.inline) + } +} + +struct SignOutHelpView: View { + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: CloserSpacing.md) { + Text("Your data is saved securely in the cloud. When you sign back in, everything will be right where you left it — your answers, memories, and connection will all be restored.") + .font(CloserFont.body) + .foregroundColor(.closerText) + + Text("Note:") + .font(CloserFont.headline) + .foregroundColor(.closerText) + .padding(.top) + + BulletPoint("Your partner can still access shared data") + BulletPoint("Push notifications may stop until you sign back in") + BulletPoint("If you're the only one in the couple, you may need to re-invite your partner") + } + .closerPadding() + } + .background(Color.closerBackground) + .navigationTitle("Sign Out") + .navigationBarTitleDisplayMode(.inline) + } +} + +struct PairingHelpView: View { + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: CloserSpacing.md) { + Text("After creating your account, go to Settings > Connection to generate a unique 6-character invite code. Share this code with your partner — they enter it on their end to connect your accounts.") + .font(CloserFont.body) + .foregroundColor(.closerText) + + BulletPoint("Codes expire after a set time — generate a new one if needed") + BulletPoint("Each account can only be in one couple at a time") + BulletPoint("Both of you need a Closer account first") + } + .closerPadding() + } + .background(Color.closerBackground) + .navigationTitle("Pairing") + .navigationBarTitleDisplayMode(.inline) + } +} + +struct PremiumHelpView: View { + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: CloserSpacing.md) { + Text("Closer Premium unlocks deeper connection tools:") + .font(CloserFont.body) + .foregroundColor(.closerText) + + FeatureBullet("sparkles", "Desire Sync", "Align your desires and dreams") + FeatureBullet("mountain.2.fill", "Connection Challenges", "Multi-day programs") + FeatureBullet("clock.fill", "Memory Lane", "Time capsules for your relationship") + FeatureBullet("questionmark.bubble.fill", "Unlimited Packs", "All premium question packs") + FeatureBullet("chart.bar.fill", "Advanced Insights", "Deeper relationship analytics") + FeatureBullet("heart.fill", "Priority Support", "Faster customer support") + + Text("\n$4.99/month. Cancel anytime through your Apple ID subscription settings.") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + } + .closerPadding() + } + .background(Color.closerBackground) + .navigationTitle("Premium") + .navigationBarTitleDisplayMode(.inline) + } +} + +struct EncryptionHelpView: View { + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: CloserSpacing.md) { + Text("Your answers and private data are encrypted end-to-end using industry-standard encryption. Only you and your partner have the keys to read your shared content — not even Closer's servers can access it.") + .font(CloserFont.body) + .foregroundColor(.closerText) + + BulletPoint("Encryption keys stay on your device") + BulletPoint("Data is encrypted before it leaves your phone") + BulletPoint("Your partner's device decrypts it on arrival") + BulletPoint("Your encryption key is backed up securely for recovery") + } + .closerPadding() + } + .background(Color.closerBackground) + .navigationTitle("Encryption") + .navigationBarTitleDisplayMode(.inline) + } +} + +struct GenericHelpView: View { + let title: String + let body: String + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: CloserSpacing.md) { + Text(body) + .font(CloserFont.body) + .foregroundColor(.closerText) + } + .closerPadding() + } + .background(Color.closerBackground) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Paywall + +struct PaywallView: View { + @Environment(\.dismiss) private var dismiss + @State private var isPurchasing = false + @State private var showError = false + @State private var showSuccess = false + @State private var selectedPlan: Plan = .monthly + + enum Plan: String, CaseIterable { + case monthly = "monthly" + case yearly = "yearly" + + var price: String { + switch self { + case .monthly: return "$4.99" + case .yearly: return "$29.99" + } + } + + var period: String { + switch self { + case .monthly: return "/month" + case .yearly: return "/year" + } + } + + var savings: String? { + switch self { + case .monthly: return nil + case .yearly: return "Save 50%" + } + } + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: CloserSpacing.xxl) { + // Header + VStack(spacing: CloserSpacing.md) { + Image(systemName: "sparkles") + .font(.system(size: 48)) + .foregroundColor(.closerGold) + + Text("Closer Premium") + .font(CloserFont.title1) + .foregroundColor(.closerText) + + Text("Deepen your connection with exclusive features") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + } + .padding(.top, CloserSpacing.xxl) + + // Features + VStack(alignment: .leading, spacing: CloserSpacing.lg) { + PremiumFeatureRow(icon: "sparkles", title: "Desire Sync", description: "Align your desires and dreams") + PremiumFeatureRow(icon: "mountain.2.fill", title: "Connection Challenges", description: "Multi-day programs to strengthen your bond") + PremiumFeatureRow(icon: "clock.fill", title: "Memory Lane", description: "Create and unlock time capsules") + PremiumFeatureRow(icon: "questionmark.bubble.fill", title: "Unlimited Packs", description: "Access all premium question packs") + PremiumFeatureRow(icon: "chart.bar.fill", title: "Advanced Insights", description: "Deeper relationship analytics and trends") + PremiumFeatureRow(icon: "heart.fill", title: "Premium Support", description: "Priority customer support") + } + .padding() + .closerCard() + .closerPadding() + + // Plan selector + VStack(spacing: CloserSpacing.md) { + ForEach(Plan.allCases, id: \.self) { plan in + Button(action: { selectedPlan = plan }) { + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text("\(plan.price)\(plan.period)") + .font(CloserFont.title3) + .foregroundColor(.closerText) + if let savings = plan.savings { + Text(savings) + .font(CloserFont.caption) + .foregroundColor(.closerSuccess) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.closerSuccess.opacity(0.1)) + .cornerRadius(CloserRadius.full) + } + } + if plan == .yearly { + Text("Billed annually — cancel anytime") + .font(CloserFont.caption) + .foregroundColor(.closerTextSecondary) + } + } + Spacer() + if selectedPlan == plan { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.closerPrimary) + } else { + Circle() + .stroke(Color.closerDivider) + .frame(width: 22, height: 22) + } + } + .padding() + .background(selectedPlan == plan ? Color.closerPrimary.opacity(0.05) : Color.closerSurface) + .cornerRadius(CloserRadius.medium) + .overlay( + RoundedRectangle(cornerRadius: CloserRadius.medium) + .stroke(selectedPlan == plan ? Color.closerPrimary : Color.closerDivider) + ) + } + .buttonStyle(.plain) + } + } + .closerPadding() + + if showSuccess { + HStack(spacing: CloserSpacing.sm) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.closerSuccess) + Text("Welcome to Premium!") + .font(CloserFont.callout) + .foregroundColor(.closerSuccess) + } + .padding() + .background(Color.closerSuccess.opacity(0.1)) + .cornerRadius(CloserRadius.medium) + } + + // Subscribe button + Button(action: purchase) { + if isPurchasing { + ProgressView() + .tint(.white) + } else { + Text("Try Premium Free") + } + } + .buttonStyle(PrimaryButtonStyle(isDisabled: isPurchasing)) + .disabled(isPurchasing) + .closerPadding() + + // Restore + Button("Restore Purchases") { + Task { + do { + let _ = try await Purchases.shared.restorePurchases() + showSuccess = true + } catch { + showError = true + } + } + } + .font(CloserFont.callout) + .foregroundColor(.closerPrimary) + + // Terms + Text("Subscription automatically renews unless cancelled at least 24 hours before the end of the current period. Manage in your Apple ID Settings.") + .font(CloserFont.caption2) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + .closerPadding() + } + } + .background(Color.closerBackground) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Close") { dismiss() } + } + } + .alert("Purchase Error", isPresented: $showError) { + Button("OK", role: .cancel) {} + } message: { + Text("Unable to process your purchase. Please try again later.") + } + } + } + + private func purchase() { + isPurchasing = true + Task { + do { + let _ = try await BillingService.shared.purchase() + await MainActor.run { + isPurchasing = false + showSuccess = true + } + } catch { + await MainActor.run { + isPurchasing = false + showError = true + } + } + } + } +} + +// MARK: - Premium Badge + +struct PremiumBadge: View { + var body: some View { + HStack(spacing: 2) { + Image(systemName: "sparkle") + .font(.system(size: 8)) + Text("Premium") + .font(.system(size: 9, weight: .bold)) + } + .foregroundColor(.closerGold) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.closerGold.opacity(0.15)) + .cornerRadius(CloserRadius.full) + } +} + +// MARK: - Premium Feature Row + +struct PremiumFeatureRow: View { + let icon: String + let title: String + let description: String + + var body: some View { + HStack(spacing: CloserSpacing.md) { + Image(systemName: icon) + .font(.title3) + .foregroundColor(.closerGold) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(CloserFont.body) + .foregroundColor(.closerText) + Text(description) + .font(CloserFont.caption) + .foregroundColor(.closerTextSecondary) + } + } + } +} + +// MARK: - Helper Views + +struct BulletPoint: View { + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + HStack(alignment: .top, spacing: CloserSpacing.sm) { + Text("\u{2022}") + .foregroundColor(.closerPrimary) + Text(text) + .font(CloserFont.body) + .foregroundColor(.closerText) + } + } +} + +struct FeatureBullet: View { + let icon: String + let title: String + let description: String + + init(_ icon: String, _ title: String, _ description: String) { + self.icon = icon + self.title = title + self.description = description + } + + var body: some View { + HStack(spacing: CloserSpacing.md) { + Image(systemName: icon) + .font(.callout) + .foregroundColor(.closerGold) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(CloserFont.body) + .foregroundColor(.closerText) + Text(description) + .font(CloserFont.caption) + .foregroundColor(.closerTextSecondary) + } + } + .padding(.vertical, 4) + } +} + +// MARK: - Entitlement Checker + +protocol EntitlementChecking { + func hasPremium() async -> Bool +} + +struct DefaultEntitlementChecker: EntitlementChecking { + func hasPremium() async -> Bool { + do { + let customerInfo = try await Purchases.shared.customerInfo() + return customerInfo.entitlements.active["closer_premium"] != nil + } catch { + return false + } + } +} + +#if DEBUG +struct MockEntitlementChecker: EntitlementChecking { + let isPremium: Bool + func hasPremium() async -> Bool { isPremium } +} +#endif \ No newline at end of file diff --git a/iphone/Closer/Theme/CloserTheme.swift b/iphone/Closer/Theme/CloserTheme.swift new file mode 100644 index 00000000..721271fc --- /dev/null +++ b/iphone/Closer/Theme/CloserTheme.swift @@ -0,0 +1,175 @@ +import SwiftUI + +// MARK: - Theme + +extension Color { + // Primary palette + static let closerPrimary = Color(hex: "B98AF4") + static let closerSecondary = Color(hex: "E7A2D1") + static let closerBackground = Color(hex: "FFFBFE") + static let closerSurface = Color(hex: "F5F0FF") + static let closerOnPrimary = Color.white + static let closerText = Color(hex: "1C1B1F") + static let closerTextSecondary = Color(hex: "49454F") + static let closerDivider = Color(hex: "E6E0E9") + + // Semantic + static let closerSuccess = Color(hex: "4CAF50") + static let closerWarning = Color(hex: "FF9800") + static let closerDanger = Color(hex: "F44336") + static let closerGold = Color(hex: "FFD700") + + // Category colors + static let categoryCommunication = Color(hex: "B98AF4") + static let categoryIntimacy = Color(hex: "E7A2D1") + static let categoryFun = Color(hex: "FFB74D") + static let categoryGoals = Color(hex: "81C784") + static let categoryAdventure = Color(hex: "64B5F6") + + // Streak + static let streakActive = Color(hex: "FF6B6B") + static let streakInactive = Color(hex: "E0E0E0") + + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 6: + (a, r, g, b) = (255, (int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF) + case 8: + (a, r, g, b) = ((int >> 24) & 0xFF, (int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} + +// MARK: - Typography + +enum CloserFont { + static let largeTitle = Font.system(size: 34, weight: .bold, design: .default) + static let title1 = Font.system(size: 28, weight: .bold) + static let title2 = Font.system(size: 22, weight: .semibold) + static let title3 = Font.system(size: 20, weight: .semibold) + static let headline = Font.system(size: 17, weight: .semibold) + static let body = Font.system(size: 17, weight: .regular) + static let callout = Font.system(size: 16, weight: .regular) + static let subheadline = Font.system(size: 15, weight: .regular) + static let footnote = Font.system(size: 13, weight: .regular) + static let caption = Font.system(size: 12, weight: .regular) +} + +// MARK: - Spacing + +enum CloserSpacing { + static let xs: CGFloat = 4 + static let sm: CGFloat = 8 + static let md: CGFloat = 12 + static let lg: CGFloat = 16 + static let xl: CGFloat = 24 + static let xxl: CGFloat = 32 + static let xxxl: CGFloat = 48 +} + +// MARK: - Corner Radius + +enum CloserRadius { + static let small: CGFloat = 8 + static let medium: CGFloat = 12 + static let large: CGFloat = 16 + static let xlarge: CGFloat = 24 + static let full: CGFloat = 999 +} + +// MARK: - Shadow + +extension View { + func closerShadow(level: ShadowLevel = .medium) -> some View { + switch level { + case .small: + return self.shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) + case .medium: + return self.shadow(color: .black.opacity(0.12), radius: 8, x: 0, y: 4) + case .large: + return self.shadow(color: .black.opacity(0.15), radius: 16, x: 0, y: 8) + } + } +} + +enum ShadowLevel { + case small, medium, large +} + +// MARK: - Button Styles + +struct PrimaryButtonStyle: ButtonStyle { + let isDisabled: Bool + + init(isDisabled: Bool = false) { + self.isDisabled = isDisabled + } + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(CloserFont.headline) + .foregroundColor(.closerOnPrimary) + .frame(maxWidth: .infinity) + .padding(.vertical, CloserSpacing.lg) + .background(isDisabled ? Color.closerPrimary.opacity(0.4) : Color.closerPrimary) + .cornerRadius(CloserRadius.large) + .opacity(configuration.isPressed ? 0.9 : 1.0) + } +} + +struct SecondaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(CloserFont.headline) + .foregroundColor(.closerPrimary) + .frame(maxWidth: .infinity) + .padding(.vertical, CloserSpacing.lg) + .background(Color.closerSurface) + .cornerRadius(CloserRadius.large) + .overlay( + RoundedRectangle(cornerRadius: CloserRadius.large) + .stroke(Color.closerPrimary, lineWidth: 1.5) + ) + .opacity(configuration.isPressed ? 0.8 : 1.0) + } +} + +// MARK: - View Modifiers + +struct CloserCardModifier: ViewModifier { + func body(content: Content) -> some View { + content + .background(Color.closerSurface) + .cornerRadius(CloserRadius.large) + .closerShadow(level: .small) + } +} + +extension View { + func closerCard() -> some View { + modifier(CloserCardModifier()) + } + + func closerPadding() -> some View { + padding(.horizontal, CloserSpacing.xl) + } + + func closerSectionTitle() -> some View { + font(CloserFont.title3) + .foregroundColor(.closerText) + .padding(.bottom, CloserSpacing.sm) + } +} \ No newline at end of file diff --git a/iphone/Closer/Wheel/WheelViews.swift b/iphone/Closer/Wheel/WheelViews.swift new file mode 100644 index 00000000..1017bbc2 --- /dev/null +++ b/iphone/Closer/Wheel/WheelViews.swift @@ -0,0 +1,408 @@ +import SwiftUI + +// MARK: - Category Picker + +struct CategoryPickerView: View { + @State private var selectedCategory: String? + + let categories: [(name: String, icon: String, color: Color)] = [ + ("Communication", "bubble.left.and.bubble.right.fill", .categoryCommunication), + ("Intimacy", "heart.fill", .categoryIntimacy), + ("Fun", "gamecontroller.fill", .categoryFun), + ("Goals", "target", .categoryGoals), + ("Adventure", "paperplane.fill", .categoryAdventure), + ("Romance", "sparkles", .closerSecondary), + ("Deep", "brain.head.profile", .closerPrimary), + ("Random", "shuffle", .closerTextSecondary), + ] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: CloserSpacing.xl) { + Text("Choose a Category") + .font(CloserFont.title1) + .foregroundColor(.closerText) + .closerPadding() + + Text("Pick a topic and spin the wheel!") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .closerPadding() + + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: CloserSpacing.md) { + ForEach(categories, id: \.name) { category in + Button(action: { selectedCategory = category.name }) { + VStack(spacing: CloserSpacing.sm) { + CategoryGlyph(name: category.name, color: category.color, isLarge: true) + Text(category.name) + .font(CloserFont.headline) + .foregroundColor(.closerText) + } + .padding(CloserSpacing.md) + .frame(maxWidth: .infinity) + .closerCard() + } + .buttonStyle(.plain) + } + } + .closerPadding() + } + .padding(.vertical) + } + .background(Color.closerBackground) + .navigationTitle("Spin the Wheel") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(item: $selectedCategory) { category in + SpinWheelView(category: category) + } + } +} + +// MARK: - Spin Wheel + +struct SpinWheelView: View { + let category: String + @State private var rotation: Double = 0 + @State private var isSpinning = false + @State private var selectedSlice: Int? + @State private var showResult = false + @State private var navigateToSession = false + + let slices: [(label: String, color: Color)] = [ + ("Question", .closerPrimary), + ("Challenge", .closerSecondary), + ("Compliment", .categoryCommunication), + ("Share", .categoryGoals), + ("Date", .categoryAdventure), + ("Story", .categoryFun), + ("Memory", .closerGold), + ("Dream", .categoryIntimacy), + ] + + var body: some View { + VStack(spacing: CloserSpacing.xl) { + Text("Category: \(category)") + .font(CloserFont.title2) + .foregroundColor(.closerText) + + // Wheel + ZStack { + ForEach(slices.indices, id: \.self) { index in + WheelSlice( + label: slices[index].label, + color: slices[index].color, + startAngle: Double(index) * (360.0 / Double(slices.count)), + endAngle: Double(index + 1) * (360.0 / Double(slices.count)) + ) + } + + // Center circle + Circle() + .fill(Color.closerBackground) + .frame(width: 60, height: 60) + .closerShadow(level: .medium) + + // Pointer (top) + Image(systemName: "arrowtriangle.down.fill") + .font(.title) + .foregroundColor(.closerDanger) + .offset(y: -160) + } + .frame(width: 320, height: 320) + .rotationEffect(.degrees(rotation)) + .animation(isSpinning ? .spring(response: 1.5, dampingFraction: 0.6) : .default, value: rotation) + + // Result + if let slice = selectedSlice, showResult { + VStack(spacing: CloserSpacing.sm) { + Text("You got:") + .font(CloserFont.subheadline) + .foregroundColor(.closerTextSecondary) + Text(slices[slice].label) + .font(CloserFont.title1) + .foregroundColor(slices[slice].color) + } + .transition(.scale.combined(with: .opacity)) + } + + Spacer() + + // Action buttons + VStack(spacing: CloserSpacing.md) { + Button(action: spin) { + HStack { + Image(systemName: "arrow.triangle.2.circlepath") + Text(isSpinning ? "Spinning..." : "Spin!") + } + } + .buttonStyle(PrimaryButtonStyle(isDisabled: isSpinning)) + .disabled(isSpinning) + + if showResult { + Button("Continue") { + navigateToSession = true + } + .buttonStyle(SecondaryButtonStyle()) + } + } + .closerPadding() + } + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(isPresented: $navigateToSession) { + WheelSessionView(sessionId: UUID().uuidString, category: category, slice: slices[selectedSlice ?? 0].label) + } + } + + private func spin() { + isSpinning = true + showResult = false + selectedSlice = nil + + let randomSpin = Double.random(in: 1080...3600) // 3-10 full rotations + let target = randomSpin + + withAnimation(.spring(response: 1.5, dampingFraction: 0.5)) { + rotation += target + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) { + // Calculate which slice the pointer is on + let sliceAngle = 360.0 / Double(slices.count) + let normalizedRotation = rotation.truncatingRemainder(dividingBy: 360) + let pointerAngle = (360 - normalizedRotation).truncatingRemainder(dividingBy: 360) + let index = Int(pointerAngle / sliceAngle) + + selectedSlice = min(max(index, 0), slices.count - 1) + showResult = true + isSpinning = false + } + } +} + +// MARK: - Wheel Slice + +struct WheelSlice: View { + let label: String + let color: Color + let startAngle: Double + let endAngle: Double + + var body: some View { + GeometryReader { geo in + let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2) + let radius = min(geo.size.width, geo.size.height) / 2 + + Path { path in + path.move(to: center) + path.addArc( + center: center, + radius: radius, + startAngle: .degrees(startAngle - 90), + endAngle: .degrees(endAngle - 90), + clockwise: false + ) + path.closeSubpath() + } + .fill(color.opacity(0.3)) + + // Label + let midAngle = (startAngle + endAngle) / 2 - 90 + let labelRadius = radius * 0.7 + let x = center.x + labelRadius * cos(CGFloat(midAngle) * .pi / 180) + let y = center.y + labelRadius * sin(CGFloat(midAngle) * .pi / 180) + + Text(label) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.closerText) + .position(x: x, y: y) + .rotationEffect(.degrees(midAngle + 90)) + } + } +} + +// MARK: - Wheel Session + +struct WheelSessionView: View { + let sessionId: String + let category: String + let slice: String + @State private var showComplete = false + + var body: some View { + VStack(spacing: CloserSpacing.xxl) { + Image(systemName: iconForSlice(slice)) + .font(.system(size: 64)) + .foregroundColor(colorForSlice(slice)) + + Text("Your \(category) \(slice.lowercased())") + .font(CloserFont.title2) + .foregroundColor(.closerText) + .multilineTextAlignment(.center) + + Text(promptFor(category: category, slice: slice)) + .font(CloserFont.body) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + .closerPadding() + + Spacer() + + Button("Complete") { + showComplete = true + } + .buttonStyle(PrimaryButtonStyle()) + } + .closerPadding() + .background(Color.closerBackground) + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(isPresented: $showComplete) { + WheelCompleteView(sessionId: sessionId) + } + } + + private func iconForSlice(_ slice: String) -> String { + switch slice { + case "Question": return "questionmark.bubble.fill" + case "Challenge": return "mountain.2.fill" + case "Compliment": return "hand.thumbsup.fill" + case "Share": return "square.and.arrow.up.fill" + case "Date": return "heart.fill" + case "Story": return "book.fill" + case "Memory": return "clock.fill" + case "Dream": return "sparkles" + default: return "star.fill" + } + } + + private func colorForSlice(_ slice: String) -> Color { + switch slice { + case "Question": return .closerPrimary + case "Challenge": return .closerSecondary + case "Compliment": return .categoryCommunication + case "Share": return .categoryGoals + case "Date": return .categoryAdventure + case "Story": return .categoryFun + case "Memory": return .closerGold + case "Dream": return .categoryIntimacy + default: return .closerPrimary + } + } + + private func promptFor(category: String, slice: String) -> String { + // This would pull from a database of category+slice prompts + return "Take a moment to share something meaningful with your partner about \(category.lowercased()) in the form of a \(slice.lowercased())." + } +} + +// MARK: - Wheel Complete + +struct WheelCompleteView: View { + let sessionId: String + @EnvironmentObject var appState: AppState + + var body: some View { + VStack(spacing: CloserSpacing.xxl) { + HeartBurstView() + + Text("Session Complete!") + .font(CloserFont.title1) + .foregroundColor(.closerText) + + Text("Great job connecting with your partner. Every moment together makes you stronger.") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + + NavigationLink { + WheelHistoryView() + } label: { + Label("View History", systemImage: "clock.arrow.circlepath") + } + .buttonStyle(SecondaryButtonStyle()) + + Button("Back to Play") { + // Pop to root + } + .buttonStyle(PrimaryButtonStyle()) + } + .closerPadding() + .background(Color.closerBackground) + .navigationBarBackButtonHidden() + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Wheel History + +struct WheelHistoryView: View { + @State private var sessions: [QuestionSession] = [] + @State private var isLoading = true + + var body: some View { + List { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + } else if sessions.isEmpty { + EmptyStateView( + icon: "clock.arrow.circlepath", + title: "No Wheel Sessions Yet", + message: "Spin the wheel to start building your history!" + ) + .listRowBackground(Color.clear) + } else { + ForEach(sessions) { session in + VStack(alignment: .leading, spacing: 4) { + Text("Wheel Session") + .font(CloserFont.body) + .foregroundColor(.closerText) + Text(session.startedAt, style: .date) + .font(CloserFont.caption) + .foregroundColor(.closerTextSecondary) + } + .padding(.vertical, 4) + } + } + } + .listStyle(.insetGrouped) + .background(Color.closerBackground) + .navigationTitle("Wheel History") + .navigationBarTitleDisplayMode(.inline) + .task { + try? await Task.sleep(nanoseconds: 500_000_000) + isLoading = false + } + } +} + +// MARK: - Navigation helper + +extension Binding where Value == String? { + func unwrapped(_ defaultValue: T) -> Binding where T == String { + Binding( + get: { self.wrappedValue as? T ?? defaultValue }, + set: { self.wrappedValue = $0 as? String } + ) + } +} + +extension View { + func navigationDestination(item: Binding, @ViewBuilder destination: @escaping (T) -> some View) -> some View { + background( + NavigationLink( + isActive: Binding( + get: { item.wrappedValue != nil }, + set: { if !$0 { item.wrappedValue = nil } } + ), + destination: { + if let value = item.wrappedValue { + destination(value) + } + }, + label: EmptyView.init + ) + .hidden() + ) + } +} \ No newline at end of file diff --git a/iphone/Package.swift b/iphone/Package.swift new file mode 100644 index 00000000..3abbfa53 --- /dev/null +++ b/iphone/Package.swift @@ -0,0 +1,43 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Closer", + platforms: [ + .iOS(.v17) + ], + dependencies: [ + // Firebase + .package(url: "https://github.com/firebase/firebase-ios-sdk.git", from: "11.0.0"), + + // RevenueCat + .package(url: "https://github.com/RevenueCat/purchases-ios.git", from: "5.0.0"), + + // Google Sign-In + .package(url: "https://github.com/google/GoogleSignIn-iOS.git", from: "8.0.0"), + ], + targets: [ + .target( + name: "Closer", + dependencies: [ + .product(name: "FirebaseAuth", package: "firebase-ios-sdk"), + .product(name: "FirebaseFirestore", package: "firebase-ios-sdk"), + .product(name: "FirebaseFunctions", package: "firebase-ios-sdk"), + .product(name: "FirebaseMessaging", package: "firebase-ios-sdk"), + .product(name: "FirebaseStorage", package: "firebase-ios-sdk"), + .product(name: "RevenueCat", package: "purchases-ios"), + .product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"), + ] + ), + .testTarget( + name: "CloserTests", + dependencies: ["Closer"] + ), + .testTarget( + name: "CloserUITests", + dependencies: ["Closer"] + ), + ] +) \ No newline at end of file diff --git a/iphone/project.yml b/iphone/project.yml new file mode 100644 index 00000000..8013cd48 --- /dev/null +++ b/iphone/project.yml @@ -0,0 +1,61 @@ +name: Closer +options: + bundleIdPrefix: app.closer + deploymentTarget: + iOS: "17.0" + xcodeVersion: "16.0" +settings: + base: + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + INFOPLIST_FILE: Closer/Info.plist + DEVELOPMENT_TEAM: "" # Set your team ID here + +targets: + Closer: + type: application + platform: iOS + sources: + - path: Closer + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: app.closer.iphone + TARGETED_DEVICE_FAMILY: 1 + INFOPLIST_FILE: Closer/Info.plist + dependencies: + - framework: FirebaseAuth + - framework: FirebaseFirestore + - framework: FirebaseFunctions + - framework: FirebaseMessaging + - framework: FirebaseStorage + - sdk: RevenueCat + - sdk: GoogleSignIn + preBuildScripts: + - name: "Run SwiftLint" + script: | + if which swiftlint > /dev/null 2>&1; then + swiftlint + fi + basedOnDependencyAnalysis: false + + CloserTests: + type: bundle.unit-test + platform: iOS + sources: + - path: CloserTests + dependencies: + - target: Closer + settings: + base: + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Closer.app/Closer" + + CloserUITests: + type: bundle.ui-testing + platform: iOS + sources: + - path: CloserUITests + dependencies: + - target: Closer + settings: + base: + TEST_TARGET_NAME: Closer \ No newline at end of file