Closer/iphone/Closer/Pairing/PairingViews.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)
}
}