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.lg) { if isLoading { LoadingView(message: "Loading today's question...") .padding(.top, CloserSpacing.xxxl) } else if let question = question { TodayQuestionHeroView() .closerPadding() 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()) } } private struct TodayQuestionHeroView: View { var body: some View { VStack(alignment: .leading, spacing: CloserSpacing.md) { Image("illustration-daily-question") .resizable() .scaledToFill() .frame(maxWidth: .infinity) .frame(height: 174) .clipShape(RoundedRectangle(cornerRadius: CloserRadius.xlarge, style: .continuous)) .accessibilityHidden(true) VStack(alignment: .leading, spacing: CloserSpacing.xs) { Text("One question, enough space") .font(CloserFont.title2) .foregroundColor(.closerText) Text("Answer privately first, then reveal when you are both ready.") .font(CloserFont.callout) .foregroundColor(.closerTextSecondary) .fixedSize(horizontal: false, vertical: true) } } .padding(CloserSpacing.md) .background(Color.closerSurface) .clipShape(RoundedRectangle(cornerRadius: CloserRadius.xlarge, style: .continuous)) .closerShadow(level: .small) } } // MARK: - Question Answer struct QuestionAnswerView: View { let question: Question let onAnswered: () -> Void @State private var textAnswer = "" @State private var selectedOptions: Set = [] @State private var scaleValue: Double = 5 @State private var isSubmitting = false var body: some View { VStack(spacing: CloserSpacing.lg) { switch question.type { case "text": TextEditor(text: $textAnswer) .frame(minHeight: 120) .padding(8) .background(Color.closerBackground) .cornerRadius(CloserRadius.medium) .overlay( RoundedRectangle(cornerRadius: CloserRadius.medium) .stroke(Color.closerDivider) ) Button(action: submitAnswer) { if isSubmitting { ProgressView().tint(.white) } else { Text("Submit Answer") } } .buttonStyle(PrimaryButtonStyle(isDisabled: isSubmitting || textAnswer.isEmpty)) .disabled(isSubmitting || textAnswer.isEmpty) case "multiple_choice": if let options = question.options { ForEach(options, id: \.self) { option in Button(action: { selectedOptions = [option] }) { HStack { Image(systemName: selectedOptions.contains(option) ? "circle.fill" : "circle") .foregroundColor(.closerPrimary) Text(option) .foregroundColor(.closerText) Spacer() } .padding() .background(selectedOptions.contains(option) ? Color.closerPrimary.opacity(0.1) : Color.closerBackground) .cornerRadius(CloserRadius.medium) .overlay( RoundedRectangle(cornerRadius: CloserRadius.medium) .stroke(selectedOptions.contains(option) ? Color.closerPrimary : Color.closerDivider) ) } } Button(action: submitAnswer) { if isSubmitting { ProgressView().tint(.white) } else { Text("Submit Answer") } } .buttonStyle(PrimaryButtonStyle(isDisabled: isSubmitting || selectedOptions.isEmpty)) .disabled(isSubmitting || selectedOptions.isEmpty) } case "scale": VStack(spacing: CloserSpacing.sm) { Text("\(Int(scaleValue))") .font(CloserFont.largeTitle) .foregroundColor(.closerPrimary) Slider(value: $scaleValue, in: 1...10, step: 1) .tint(.closerPrimary) HStack { Text("1").font(CloserFont.caption).foregroundColor(.closerTextSecondary) Spacer() Text("10").font(CloserFont.caption).foregroundColor(.closerTextSecondary) } } Button(action: submitAnswer) { if isSubmitting { ProgressView().tint(.white) } else { Text("Submit Answer") } } .buttonStyle(PrimaryButtonStyle(isDisabled: isSubmitting)) .disabled(isSubmitting) default: Text("Unsupported question type") .font(CloserFont.callout) .foregroundColor(.closerTextSecondary) } } } private func submitAnswer() { isSubmitting = true Task { try? await Task.sleep(nanoseconds: 500_000_000) // Simulate submit isSubmitting = false onAnswered() } } } // MARK: - Answer Reveal struct AnswerRevealView: View { let questionId: String @EnvironmentObject var appState: AppState @State private var partnerAnswer: String? @State private var isLoading = true @State private var showCreateInvite = false private var isPaired: Bool { appState.currentUser?.coupleId != nil } var body: some View { VStack(spacing: CloserSpacing.xxl) { if !isPaired { EmptyStateView( icon: "lock.fill", title: "Connect your partner", message: "Invite your partner to unlock shared reveals and private answers you can open together.", action: (title: "Invite partner", handler: { showCreateInvite = true }) ) } else 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) .fullScreenCover(isPresented: $showCreateInvite) { CreateInviteView() .environmentObject(appState) } .task { // Load partner's answer try? await Task.sleep(nanoseconds: 800_000_000) isLoading = false } } } // MARK: - Answer History struct AnswerHistoryView: View { @EnvironmentObject var appState: AppState @State private var answers: [Answer] = [] @State private var isLoading = true @State private var showCreateInvite = false private var isPaired: Bool { appState.currentUser?.coupleId != nil } private var emptyAction: (title: String, handler: () -> Void)? { isPaired ? nil : (title: "Invite partner", handler: { showCreateInvite = true }) } var body: some View { List { if isLoading { ProgressView() .frame(maxWidth: .infinity) } else if answers.isEmpty { EmptyStateView( icon: "clock.arrow.circlepath", title: isPaired ? "No Answers Yet" : "Connect your partner", message: isPaired ? "Your answer history will appear here." : "Invite your partner to unlock shared reveals and build your private answer history together.", illustrationName: "illustration-couple-history", action: emptyAction ) .listRowBackground(Color.clear) } else { ForEach(answers) { answer in if isPaired { NavigationLink { AnswerRevealView(questionId: answer.questionId) } label: { AnswerHistoryRow(answer: answer) } } else { Button { showCreateInvite = true } label: { AnswerHistoryRow(answer: answer) } } } } } .listStyle(.insetGrouped) .background(Color.closerBackground) .navigationTitle("Answer History") .navigationBarTitleDisplayMode(.inline) .fullScreenCover(isPresented: $showCreateInvite) { CreateInviteView() .environmentObject(appState) } .task { // Load answers try? await Task.sleep(nanoseconds: 500_000_000) isLoading = false } } } private struct AnswerHistoryRow: View { let answer: Answer var body: some View { 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) } } // 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) { Image(packArtworkName(pack.id)) .resizable() .scaledToFill() .frame(maxWidth: .infinity) .frame(height: 82) .clipShape(RoundedRectangle(cornerRadius: CloserRadius.medium, style: .continuous)) 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() } } private func packArtworkName(_ packId: String) -> String { switch packId { case "communication": return "pack-art-communication" case "trust", "boundaries", "conflict", "conflict_repair", "rebuilding_trust": return "pack-art-trust-repair" case "emotional_intimacy", "gratitude", "couple_intimacy": return "pack-art-intimacy" case "fun", "date_night", "quality_time": return "pack-art-fun-date" case "future", "values": return "pack-art-future-goals" case "home_life", "stress": return "pack-art-home-life" case "money": return "pack-art-money-values" case "marriage", "parenting": return "pack-art-family-commitment" case "sex_and_desire", "sexual_preferences", "physical_intimacy": return "pack-art-desire" case "difficult_conversations": return "pack-art-deep-reflection" default: return "pack-art-deep-reflection" } } // 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 // 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: "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: "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: "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), ]