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:
parent
c621c9fec5
commit
67251537eb
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue