Closer/iphone/Closer/Pairing/PairingViews.swift

336 lines
12 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 {
VStack(spacing: CloserSpacing.xxl) {
Spacer()
CloserIllustrationView(imageName: "illustration-couple-invite", size: 190)
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("Invite my partner", systemImage: "plus.circle.fill")
}
.buttonStyle(PrimaryButtonStyle())
Button(action: { showAcceptInvite = true }) {
Label("Enter a code", systemImage: "key.fill")
}
.buttonStyle(SecondaryButtonStyle())
}
.closerPadding()
Spacer()
}
.background(Color.closerBackground)
.navigationDestination(isPresented: $showCreateInvite) {
CreateInviteView()
}
.navigationDestination(isPresented: $showAcceptInvite) {
AcceptInviteView()
}
}
}
}
// 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?
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)
}
private func acceptInvite() {
guard code.count == 6 else { return }
isLoading = true
errorMessage = nil
Task {
do {
let coupleId = try await FirestoreService.shared.acceptInviteCallable(code: code)
await appState.refreshData()
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
}
// MARK: - Invite Confirm
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)
}
}