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)
This commit is contained in:
null 2026-06-20 17:15:25 -05:00
parent c621c9fec5
commit 67251537eb
24 changed files with 7270 additions and 0 deletions

1108
iphone/ARCHITECTURE_AUDIT.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)app.closer.iphone</string>
</array>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>

View File

@ -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<Void, Never>?
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

View File

@ -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)
}
}

View File

@ -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<AuthState> {
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
}
}
}

View File

@ -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<Void, Never>?
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<CustomerInfo> {
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<Bool> { get }
func hasPremium() async -> Bool
}
final actor DefaultEntitlementChecker: EntitlementChecker {
nonisolated let isPremium: AsyncStream<Bool>
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()
}
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)
}
}

76
iphone/Closer/Info.plist Normal file
View File

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Closer</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.2.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.google.ReversedClientID</string>
<key>CFBundleURLSchemes</key>
<array>
<string>$(GOOGLE_REVERSED_CLIENT_ID)</string>
</array>
</dict>
<dict>
<key>CFBundleURLName</key>
<string>app.closer.iphone</string>
<key>CFBundleURLSchemes</key>
<array>
<string>closer</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>FirebaseAppDelegateProxyEnabled</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Take a profile photo or add photos to memory capsules.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose photos for your profile or memory capsules.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
</array>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>LaunchBackground</string>
</dict>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Light</string>
</dict>
</plist>

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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<String> = []
@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),
]

View File

@ -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<T: Encodable>(_ 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<T: Decodable>(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<T: Decodable>(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<T: Decodable>(
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
}
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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<T: Hashable>(_ defaultValue: T) -> Binding<T> where T == String {
Binding<T>(
get: { self.wrappedValue as? T ?? defaultValue },
set: { self.wrappedValue = $0 as? String }
)
}
}
extension View {
func navigationDestination<T: Hashable>(item: Binding<T?>, @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()
)
}
}

43
iphone/Package.swift Normal file
View File

@ -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"]
),
]
)

61
iphone/project.yml Normal file
View File

@ -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