554 lines
19 KiB
Swift
554 lines
19 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Pair Prompt
|
|
|
|
struct PairPromptView: View {
|
|
@State private var showCreateInvite = false
|
|
@State private var showAcceptInvite = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(spacing: CloserSpacing.xl) {
|
|
VStack(alignment: .leading, spacing: CloserSpacing.sm) {
|
|
Text("You're in")
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerTextSecondary)
|
|
Text("Set up your shared space")
|
|
.font(CloserFont.title1)
|
|
.foregroundColor(.closerText)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.closerPadding()
|
|
.padding(.top, CloserSpacing.xl)
|
|
|
|
PartnerActivationCard(
|
|
onInvite: { showCreateInvite = true },
|
|
onAcceptInvite: { showAcceptInvite = true }
|
|
)
|
|
.closerPadding()
|
|
|
|
Text("You can keep exploring Closer, but shared reveals and partner activity start after your person joins.")
|
|
.font(CloserFont.footnote)
|
|
.foregroundColor(.closerTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
.closerPadding()
|
|
|
|
Spacer()
|
|
.frame(height: CloserSpacing.xxl)
|
|
}
|
|
}
|
|
.background(Color.closerBackground)
|
|
.navigationDestination(isPresented: $showCreateInvite) {
|
|
CreateInviteView()
|
|
}
|
|
.navigationDestination(isPresented: $showAcceptInvite) {
|
|
AcceptInviteView()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Partner Activation
|
|
|
|
struct PartnerActivationCard: View {
|
|
let onInvite: () -> Void
|
|
let onAcceptInvite: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: CloserSpacing.lg) {
|
|
HStack {
|
|
Text("1 of 2 connected")
|
|
.font(CloserFont.caption)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.closerPrimary)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 7)
|
|
.background(Color.closerBackground.opacity(0.75))
|
|
.clipShape(Capsule())
|
|
|
|
Spacer()
|
|
|
|
Label("Private invite", systemImage: "lock.fill")
|
|
.font(CloserFont.caption)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.closerPrimary)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 7)
|
|
.background(Color.closerBackground.opacity(0.75))
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
Image("illustration-partner-activation")
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 148)
|
|
.clipShape(RoundedRectangle(cornerRadius: CloserRadius.large, style: .continuous))
|
|
|
|
VStack(alignment: .leading, spacing: CloserSpacing.sm) {
|
|
Text("A private space for two")
|
|
.font(CloserFont.title2)
|
|
.foregroundColor(.closerText)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Text("Invite your partner to unlock shared reveals, games, streaks, and answers you can both respond to.")
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerTextSecondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
HStack(spacing: CloserSpacing.sm) {
|
|
ActivationBenefitChip("Private reveals")
|
|
ActivationBenefitChip("Shared streak")
|
|
ActivationBenefitChip("Games for two")
|
|
}
|
|
|
|
VStack(spacing: CloserSpacing.md) {
|
|
Button(action: onInvite) {
|
|
Label("Invite partner", systemImage: "person.crop.circle.badge.plus")
|
|
}
|
|
.buttonStyle(PrimaryButtonStyle())
|
|
|
|
Button(action: onAcceptInvite) {
|
|
Label("Enter code", systemImage: "key.fill")
|
|
}
|
|
.buttonStyle(SecondaryButtonStyle())
|
|
}
|
|
}
|
|
.padding(CloserSpacing.lg)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [
|
|
Color.closerSurface,
|
|
Color.closerPrimary.opacity(0.18),
|
|
Color.closerSecondary.opacity(0.18)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: CloserRadius.xlarge, style: .continuous))
|
|
.closerShadow(level: .medium)
|
|
}
|
|
}
|
|
|
|
private struct ActivationBenefitChip: View {
|
|
let label: String
|
|
|
|
init(_ label: String) {
|
|
self.label = label
|
|
}
|
|
|
|
var body: some View {
|
|
Text(label)
|
|
.font(CloserFont.caption2)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.closerPrimary)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.78)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 8)
|
|
.background(Color.closerBackground.opacity(0.68))
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
|
|
// 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 (code, _) = try await FirestoreService.shared.createInviteCallable()
|
|
self.inviteCode = code
|
|
} 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?
|
|
@State private var showSuccess = false
|
|
|
|
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)
|
|
.fullScreenCover(isPresented: $showSuccess) {
|
|
PairingSuccessView()
|
|
.environmentObject(appState)
|
|
}
|
|
}
|
|
|
|
private func acceptInvite() {
|
|
guard code.count == 6 else { return }
|
|
isLoading = true
|
|
errorMessage = nil
|
|
|
|
Task {
|
|
do {
|
|
_ = try await FirestoreService.shared.acceptInviteCallable(code: code)
|
|
await appState.refreshData()
|
|
showSuccess = true
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
isLoading = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Pairing Success
|
|
|
|
struct PairingSuccessView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
@State private var heartScale: CGFloat = 1.0
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.closerBackground.ignoresSafeArea()
|
|
|
|
VStack(spacing: 0) {
|
|
Spacer()
|
|
|
|
avatarRow
|
|
.padding(.bottom, CloserSpacing.xl)
|
|
|
|
Text(coupleTitle)
|
|
.font(CloserFont.title2)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.closerText)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.bottom, CloserSpacing.sm)
|
|
|
|
Text("You're connected.")
|
|
.font(CloserFont.body)
|
|
.foregroundColor(.closerTextSecondary)
|
|
Text("Ready to start your story together.")
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerTextSecondary)
|
|
|
|
Spacer()
|
|
|
|
Button("Start together") {
|
|
Task { await appState.refreshData() }
|
|
}
|
|
.buttonStyle(PrimaryButtonStyle())
|
|
.padding(.bottom, CloserSpacing.xl)
|
|
}
|
|
.padding(.horizontal, CloserSpacing.xl)
|
|
}
|
|
}
|
|
|
|
private var coupleTitle: String {
|
|
let me = appState.currentUser?.displayName ?? ""
|
|
let partner = appState.currentPartner?.displayName ?? ""
|
|
if !me.isEmpty && !partner.isEmpty { return "\(me) & \(partner)" }
|
|
return "You're connected"
|
|
}
|
|
|
|
private var avatarRow: some View {
|
|
ZStack {
|
|
HStack(spacing: -20) {
|
|
PairAvatarView(url: appState.currentUser?.photoUrl, size: 80)
|
|
PairAvatarView(url: appState.currentPartner?.photoUrl, size: 80)
|
|
}
|
|
// Pulsing heart badge centered between the two circles
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.closerBackground)
|
|
.frame(width: 34, height: 34)
|
|
Circle()
|
|
.fill(Color.closerPrimary.opacity(0.18))
|
|
.frame(width: 28, height: 28)
|
|
Image(systemName: "heart.fill")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundColor(.closerPrimary)
|
|
.scaleEffect(heartScale)
|
|
}
|
|
}
|
|
.onAppear {
|
|
withAnimation(.easeInOut(duration: 0.75).repeatForever(autoreverses: true)) {
|
|
heartScale = 1.25
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PairAvatarView: View {
|
|
let url: String?
|
|
let size: CGFloat
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.closerSurface)
|
|
if let urlString = url, !urlString.isEmpty, let parsed = URL(string: urlString) {
|
|
AsyncImage(url: parsed) { phase in
|
|
if case .success(let img) = phase {
|
|
img.resizable().scaledToFill()
|
|
} else {
|
|
Image(systemName: "person.fill")
|
|
.font(.system(size: size * 0.4))
|
|
.foregroundColor(.closerTextSecondary)
|
|
}
|
|
}
|
|
} else {
|
|
Image(systemName: "person.fill")
|
|
.font(.system(size: size * 0.4))
|
|
.foregroundColor(.closerTextSecondary)
|
|
}
|
|
}
|
|
.frame(width: size, height: size)
|
|
.clipShape(Circle())
|
|
.overlay(Circle().stroke(Color.closerSurface, lineWidth: 2.5))
|
|
}
|
|
}
|
|
|
|
// MARK: - Invite Confirm (legacy — retained for back-compat)
|
|
|
|
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)
|
|
}
|
|
}
|