diff --git a/iphone/Closer/Dates/DateViews.swift b/iphone/Closer/Dates/DateViews.swift index ef2301e1..421688d8 100644 --- a/iphone/Closer/Dates/DateViews.swift +++ b/iphone/Closer/Dates/DateViews.swift @@ -85,9 +85,6 @@ struct DateMatchView: View { .background(Color.closerBackground) .navigationTitle("Date Ideas") .navigationBarTitleDisplayMode(.inline) - .navigationDestination(isPresented: .constant(false)) { - DateBuilderView() - } } private func swipe(_ direction: SwipeDirection) { diff --git a/iphone/Closer/Navigation/ContentView.swift b/iphone/Closer/Navigation/ContentView.swift index 8d65ffd9..9371f563 100644 --- a/iphone/Closer/Navigation/ContentView.swift +++ b/iphone/Closer/Navigation/ContentView.swift @@ -2,22 +2,28 @@ 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() - } + NavigationStack { + rootView + } + .environmentObject(appState) + } + + @ViewBuilder + private var rootView: some View { + 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() } } } @@ -28,52 +34,42 @@ struct ContentView: 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) + HomeView() + .tabItem { + Label("Home", systemImage: selectedTab == .home ? "house.fill" : "house") + } + .tag(Tab.home) + + DailyQuestionView() + .tabItem { + Label("Today", systemImage: selectedTab == .dailyQuestion ? "heart.fill" : "heart") + } + .tag(Tab.dailyQuestion) + + PlayHubView() + .tabItem { + Label("Play", systemImage: selectedTab == .play ? "play.fill" : "play") + } + .tag(Tab.play) + + QuestionPackLibraryView() + .tabItem { + Label("Packs", systemImage: selectedTab == .questionPacks ? "star.fill" : "star") + } + .tag(Tab.questionPacks) + + SettingsView() + .tabItem { + Label("Settings", systemImage: selectedTab == .settings ? "gearshape.fill" : "gearshape") + } + .tag(Tab.settings) } .tint(.closerPrimary) } @@ -84,17 +80,15 @@ struct MainTabView: View { 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() - } - } + OnboardingView(showLogin: $showLogin, showSignUp: $showSignUp) + .navigationDestination(isPresented: $showLogin) { + LoginView() + } + .navigationDestination(isPresented: $showSignUp) { + SignUpView() + } } } diff --git a/iphone/Closer/Pairing/PairingViews.swift b/iphone/Closer/Pairing/PairingViews.swift index c920128e..a557b222 100644 --- a/iphone/Closer/Pairing/PairingViews.swift +++ b/iphone/Closer/Pairing/PairingViews.swift @@ -6,45 +6,45 @@ 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) @@ -68,7 +68,7 @@ struct CreateInviteView: View { @State private var inviteCode = "" @State private var isLoading = false @State private var errorMessage: String? - + var body: some View { ScrollView { VStack(spacing: CloserSpacing.xxl) { @@ -85,7 +85,7 @@ struct CreateInviteView: View { .multilineTextAlignment(.center) } .padding(.top, CloserSpacing.xxl) - + if !inviteCode.isEmpty { VStack(spacing: CloserSpacing.md) { Text(inviteCode) @@ -95,13 +95,13 @@ struct CreateInviteView: View { .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") } @@ -116,13 +116,13 @@ struct CreateInviteView: View { } .buttonStyle(PrimaryButtonStyle()) } - + if let error = errorMessage { Text(error) .font(CloserFont.caption) .foregroundColor(.closerDanger) } - + NavigationLink { InviteConfirmView() } label: { @@ -136,17 +136,23 @@ struct CreateInviteView: View { .background(Color.closerBackground) .navigationBarTitleDisplayMode(.inline) } - + private func generateInvite() { isLoading = true errorMessage = nil - + Task { do { + // TODO: Move invite creation to createInviteCallable Cloud Function. + // 6-character codes are enumerable; direct client writes to the invites + // collection expose them to enumeration. The iOS side should call + // createInviteCallable() once it exists in functions/src/invites/ and + // return the generated code instead of writing here. Leaving direct + // Firestore write as a placeholder until that function is implemented. let userId = try FirestoreService.shared.userId() let code = generateSixCharCode() self.inviteCode = code - + let invite = Invite( id: code, code: code, @@ -159,7 +165,7 @@ struct CreateInviteView: View { acceptedAt: nil, acceptedByUserId: nil ) - + let inviteRef = FirestoreService.shared.inviteDocument(code) try await FirestoreService.shared.setDocument(invite, at: inviteRef, merge: false) } catch { @@ -168,11 +174,11 @@ struct CreateInviteView: View { 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, @@ -181,7 +187,7 @@ struct CreateInviteView: View { 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()! }) @@ -195,7 +201,7 @@ struct AcceptInviteView: View { @State private var code = "" @State private var isLoading = false @State private var errorMessage: String? - + var body: some View { ScrollView { VStack(spacing: CloserSpacing.xxl) { @@ -212,7 +218,7 @@ struct AcceptInviteView: View { .multilineTextAlignment(.center) } .padding(.top, CloserSpacing.xxl) - + VStack(spacing: CloserSpacing.md) { TextField("XXXXXX", text: $code) .font(.system(size: 32, weight: .bold, design: .monospaced)) @@ -228,13 +234,13 @@ struct AcceptInviteView: View { code = String(newValue.prefix(6)) } } - + if let error = errorMessage { Text(error) .font(CloserFont.caption) .foregroundColor(.closerDanger) } - + Button(action: acceptInvite) { if isLoading { ProgressView().tint(.white) @@ -251,12 +257,12 @@ struct AcceptInviteView: View { .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) @@ -277,7 +283,7 @@ struct EmailInviteView: View { @State private var isLoading = false @State private var errorMessage: String? @State private var successMessage: String? - + var body: some View { ScrollView { VStack(spacing: CloserSpacing.xxl) { @@ -293,7 +299,7 @@ struct EmailInviteView: View { .foregroundColor(.closerTextSecondary) } .padding(.top, CloserSpacing.xxl) - + VStack(spacing: CloserSpacing.md) { TextField("partner@example.com", text: $email) .textContentType(.emailAddress) @@ -303,7 +309,7 @@ struct EmailInviteView: View { .padding() .background(Color.closerSurface) .cornerRadius(CloserRadius.medium) - + if let success = successMessage { Text(success) .font(CloserFont.callout) @@ -314,7 +320,7 @@ struct EmailInviteView: View { .font(CloserFont.caption) .foregroundColor(.closerDanger) } - + Button(action: sendInvite) { if isLoading { ProgressView().tint(.white) @@ -331,19 +337,22 @@ struct EmailInviteView: View { .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 { + // TODO: Use createInviteCallable Cloud Function instead of direct + // client writes to the invites collection. Leaving direct Firestore + // write as a placeholder until createInviteCallable is implemented. let userId = try FirestoreService.shared.userId() let code = generateSixCharCode() - + let invite = Invite( id: code, code: code, @@ -356,7 +365,7 @@ struct EmailInviteView: View { 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 { @@ -365,7 +374,7 @@ struct EmailInviteView: View { isLoading = false } } - + private func generateSixCharCode() -> String { let chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" return String((0..<6).map { _ in chars.randomElement()! }) @@ -376,22 +385,22 @@ struct EmailInviteView: View { 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() } } @@ -411,21 +420,21 @@ struct RecoveryView: View { 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() @@ -442,21 +451,21 @@ struct EncryptionUpgradeView: View { 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() diff --git a/iphone/Closer/Play/PlayViews.swift b/iphone/Closer/Play/PlayViews.swift index 935d54f9..9b0b5e0f 100644 --- a/iphone/Closer/Play/PlayViews.swift +++ b/iphone/Closer/Play/PlayViews.swift @@ -263,24 +263,8 @@ struct ThisOrThatView: View { .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 + let options = [pairs[currentPair].0, pairs[currentPair].1] + ForEach(options, id: \.self) { option in Button(action: { choose(option) }) { Text(option) .font(CloserFont.body) diff --git a/iphone/Closer/Questions/QuestionViews.swift b/iphone/Closer/Questions/QuestionViews.swift index e95347df..5d271bb8 100644 --- a/iphone/Closer/Questions/QuestionViews.swift +++ b/iphone/Closer/Questions/QuestionViews.swift @@ -552,23 +552,25 @@ struct QuestionThreadView: View { // MARK: - Sample Data +// Fallback sample data. IDs must match real Android seed entries so that +// navigation to category/pack/question threads references actual 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), + Question(id: "communication_001", text: "What is one small thing I do that helps you feel heard?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: "communication", packId: nil), + Question(id: "communication_002", text: "When do you feel it is easiest to talk to me?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: "communication", packId: nil), + Question(id: "communication_151", text: "When you are upset, what helps most first?", type: "single_choice", options: ["Comfort", "Space", "Advice", "Distraction"], scaleMin: nil, scaleMax: nil, categoryId: "communication", packId: nil), + Question(id: "date_night_001", text: "What would make a simple dinner feel fun for both of us?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: "date_night", 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), + Question(id: "fun_001", text: "What is one thing we do together that always makes you smile?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: "fun", packId: nil), + Question(id: "fun_002", text: "What is one silly memory of us that you still love?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: "fun", packId: nil), + Question(id: "values_001", text: "What is one value that quietly guides your life?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: "values", packId: nil), + Question(id: "trust_001", text: "What makes it easy for you to trust someone?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: "trust", 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), + QuestionPack(id: "communication", name: "Communication", description: "Questions about listening, expressing needs, understanding each other, and talking clearly.", categories: nil, isPremium: false), + QuestionPack(id: "fun", name: "Fun & Playful", description: "Lighthearted questions about playfulness, laughter, and shared activities.", categories: nil, isPremium: false), + QuestionPack(id: "date_night", name: "Date Night", description: "Questions designed for dates, meals, walks, or quiet time together.", categories: nil, isPremium: true), + QuestionPack(id: "trust", name: "Trust", description: "Questions about trust, repair, and rebuilding.", categories: nil, isPremium: true), ] \ No newline at end of file diff --git a/iphone/Closer/Wheel/WheelViews.swift b/iphone/Closer/Wheel/WheelViews.swift index 1017bbc2..58b0c7fd 100644 --- a/iphone/Closer/Wheel/WheelViews.swift +++ b/iphone/Closer/Wheel/WheelViews.swift @@ -376,33 +376,3 @@ struct WheelHistoryView: View { } } -// MARK: - Navigation helper - -extension Binding where Value == String? { - func unwrapped(_ defaultValue: T) -> Binding where T == String { - Binding( - get: { self.wrappedValue as? T ?? defaultValue }, - set: { self.wrappedValue = $0 as? String } - ) - } -} - -extension View { - func navigationDestination(item: Binding, @ViewBuilder destination: @escaping (T) -> some View) -> some View { - background( - NavigationLink( - isActive: Binding( - get: { item.wrappedValue != nil }, - set: { if !$0 { item.wrappedValue = nil } } - ), - destination: { - if let value = item.wrappedValue { - destination(value) - } - }, - label: EmptyView.init - ) - .hidden() - ) - } -} \ No newline at end of file