Closer/iphone/Closer/Questions/QuestionViews.swift

692 lines
27 KiB
Swift

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