537 lines
20 KiB
Swift
537 lines
20 KiB
Swift
import SwiftUI
|
|
|
|
struct OnboardingView: View {
|
|
@Binding var showLogin: Bool
|
|
@Binding var showSignUp: Bool
|
|
@State private var currentPage = 0
|
|
|
|
private let pages = [
|
|
OnboardingPage(
|
|
icon: "heart.fill",
|
|
illustrationName: "illustration-couple-onboarding",
|
|
title: "Connect Deeper",
|
|
description: "Daily questions, games, and shared experiences designed to bring you closer together."
|
|
),
|
|
OnboardingPage(
|
|
icon: "lock.fill",
|
|
title: "Private & Secure",
|
|
description: "Your conversations are private. End-to-end encryption keeps your answers between you and your partner."
|
|
),
|
|
OnboardingPage(
|
|
icon: "sparkles",
|
|
title: "Grow Together",
|
|
description: "Build stronger habits, discover new things, and celebrate your journey as a couple."
|
|
)
|
|
]
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Brand mark
|
|
Image(systemName: "heart.fill")
|
|
.font(.system(size: 60))
|
|
.foregroundColor(.closerPrimary)
|
|
.padding(.top, CloserSpacing.xxxl)
|
|
|
|
Text("Closer")
|
|
.font(CloserFont.largeTitle)
|
|
.foregroundColor(.closerPrimary)
|
|
.padding(.top, CloserSpacing.sm)
|
|
|
|
Spacer()
|
|
|
|
// Carousel
|
|
TabView(selection: $currentPage) {
|
|
ForEach(pages.indices, id: \.self) { index in
|
|
OnboardingPageView(
|
|
page: pages[index]
|
|
)
|
|
.tag(index)
|
|
}
|
|
}
|
|
.tabViewStyle(.page(indexDisplayMode: .always))
|
|
.frame(height: 330)
|
|
|
|
Spacer()
|
|
|
|
// Action buttons
|
|
VStack(spacing: CloserSpacing.md) {
|
|
Button("Get Started") {
|
|
showSignUp = true
|
|
}
|
|
.buttonStyle(PrimaryButtonStyle())
|
|
|
|
HStack(spacing: 4) {
|
|
Text("Already have an account?")
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerTextSecondary)
|
|
Button("Sign In") {
|
|
showLogin = true
|
|
}
|
|
.font(CloserFont.callout.weight(.semibold))
|
|
.foregroundColor(.closerPrimary)
|
|
}
|
|
}
|
|
.closerPadding()
|
|
.padding(.bottom, CloserSpacing.xxxl)
|
|
}
|
|
.background(Color.closerBackground)
|
|
}
|
|
}
|
|
|
|
private struct OnboardingPage {
|
|
let icon: String
|
|
var illustrationName: String? = nil
|
|
let title: String
|
|
let description: String
|
|
}
|
|
|
|
private struct OnboardingPageView: View {
|
|
let page: OnboardingPage
|
|
|
|
var body: some View {
|
|
VStack(spacing: CloserSpacing.lg) {
|
|
if let illustrationName = page.illustrationName {
|
|
CloserIllustrationView(imageName: illustrationName, size: 170)
|
|
} else {
|
|
Image(systemName: page.icon)
|
|
.font(.system(size: 44))
|
|
.foregroundColor(.closerPrimary)
|
|
}
|
|
|
|
Text(page.title)
|
|
.font(CloserFont.title2)
|
|
.foregroundColor(.closerText)
|
|
|
|
Text(page.description)
|
|
.font(CloserFont.body)
|
|
.foregroundColor(.closerTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
.closerPadding()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Login
|
|
|
|
struct LoginView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var email = ""
|
|
@State private var password = ""
|
|
@State private var isLoading = false
|
|
@State private var errorMessage: String?
|
|
@State private var showForgotPassword = false
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: CloserSpacing.xxl) {
|
|
// Header
|
|
VStack(spacing: CloserSpacing.sm) {
|
|
Image(systemName: "heart.fill")
|
|
.font(.system(size: 44))
|
|
.foregroundColor(.closerPrimary)
|
|
Text("Welcome Back")
|
|
.font(CloserFont.title1)
|
|
.foregroundColor(.closerText)
|
|
Text("Sign in to continue with your partner")
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerTextSecondary)
|
|
}
|
|
.padding(.top, CloserSpacing.xxl)
|
|
|
|
// Form
|
|
VStack(spacing: CloserSpacing.lg) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Email").font(CloserFont.footnote).foregroundColor(.closerTextSecondary)
|
|
TextField("you@example.com", text: $email)
|
|
.textContentType(.emailAddress)
|
|
.keyboardType(.emailAddress)
|
|
.autocapitalization(.none)
|
|
.disableAutocorrection(true)
|
|
.padding()
|
|
.background(Color.closerSurface)
|
|
.cornerRadius(CloserRadius.medium)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Password").font(CloserFont.footnote).foregroundColor(.closerTextSecondary)
|
|
SecureField("Your password", text: $password)
|
|
.textContentType(.password)
|
|
.padding()
|
|
.background(Color.closerSurface)
|
|
.cornerRadius(CloserRadius.medium)
|
|
}
|
|
|
|
if let error = errorMessage {
|
|
Text(error)
|
|
.font(CloserFont.caption)
|
|
.foregroundColor(.closerDanger)
|
|
}
|
|
|
|
Button("Forgot Password?") {
|
|
showForgotPassword = true
|
|
}
|
|
.font(CloserFont.footnote)
|
|
.foregroundColor(.closerPrimary)
|
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
}
|
|
|
|
// Actions
|
|
VStack(spacing: CloserSpacing.md) {
|
|
Button(action: signIn) {
|
|
if isLoading {
|
|
ProgressView().tint(.white)
|
|
} else {
|
|
Text("Sign In")
|
|
}
|
|
}
|
|
.buttonStyle(PrimaryButtonStyle(isDisabled: isLoading))
|
|
.disabled(isLoading)
|
|
|
|
Button(action: signInWithGoogle) {
|
|
HStack {
|
|
Image(systemName: "g.circle.fill")
|
|
.font(.title3)
|
|
Text("Continue with Google")
|
|
}
|
|
}
|
|
.buttonStyle(SecondaryButtonStyle())
|
|
}
|
|
}
|
|
.closerPadding()
|
|
}
|
|
.background(Color.closerBackground)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.navigationDestination(isPresented: $showForgotPassword) {
|
|
ForgotPasswordView()
|
|
}
|
|
}
|
|
|
|
private func signIn() {
|
|
guard !email.isEmpty, !password.isEmpty else {
|
|
errorMessage = "Please enter email and password."
|
|
return
|
|
}
|
|
|
|
isLoading = true
|
|
errorMessage = nil
|
|
|
|
Task {
|
|
do {
|
|
_ = try await AuthService.shared.signInWithEmail(email, password: password)
|
|
dismiss()
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
isLoading = false
|
|
}
|
|
}
|
|
|
|
private func signInWithGoogle() {
|
|
// Google Sign-In requires GoogleService-Info.plist setup
|
|
// Implementation uses GIDSignIn.sharedInstance.signIn(withPresenting:)
|
|
// This will be connected once the GoogleService-Info.plist is configured
|
|
}
|
|
}
|
|
|
|
// MARK: - Sign Up
|
|
|
|
struct SignUpView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var email = ""
|
|
@State private var password = ""
|
|
@State private var confirmPassword = ""
|
|
@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: "heart.fill")
|
|
.font(.system(size: 44))
|
|
.foregroundColor(.closerPrimary)
|
|
Text("Create Account")
|
|
.font(CloserFont.title1)
|
|
.foregroundColor(.closerText)
|
|
Text("Start your journey together")
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerTextSecondary)
|
|
}
|
|
.padding(.top, CloserSpacing.xxl)
|
|
|
|
VStack(spacing: CloserSpacing.lg) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Email").font(CloserFont.footnote).foregroundColor(.closerTextSecondary)
|
|
TextField("you@example.com", text: $email)
|
|
.textContentType(.emailAddress)
|
|
.keyboardType(.emailAddress)
|
|
.autocapitalization(.none)
|
|
.disableAutocorrection(true)
|
|
.padding()
|
|
.background(Color.closerSurface)
|
|
.cornerRadius(CloserRadius.medium)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Password").font(CloserFont.footnote).foregroundColor(.closerTextSecondary)
|
|
SecureField("At least 6 characters", text: $password)
|
|
.textContentType(.newPassword)
|
|
.padding()
|
|
.background(Color.closerSurface)
|
|
.cornerRadius(CloserRadius.medium)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Confirm Password").font(CloserFont.footnote).foregroundColor(.closerTextSecondary)
|
|
SecureField("Repeat password", text: $confirmPassword)
|
|
.textContentType(.newPassword)
|
|
.padding()
|
|
.background(Color.closerSurface)
|
|
.cornerRadius(CloserRadius.medium)
|
|
}
|
|
|
|
if let error = errorMessage {
|
|
Text(error)
|
|
.font(CloserFont.caption)
|
|
.foregroundColor(.closerDanger)
|
|
}
|
|
}
|
|
|
|
VStack(spacing: CloserSpacing.md) {
|
|
Button(action: signUp) {
|
|
if isLoading {
|
|
ProgressView().tint(.white)
|
|
} else {
|
|
Text("Create Account")
|
|
}
|
|
}
|
|
.buttonStyle(PrimaryButtonStyle(isDisabled: isLoading))
|
|
.disabled(isLoading)
|
|
}
|
|
}
|
|
.closerPadding()
|
|
}
|
|
.background(Color.closerBackground)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
|
|
private func signUp() {
|
|
guard !email.isEmpty, !password.isEmpty else {
|
|
errorMessage = "Please fill in all fields."
|
|
return
|
|
}
|
|
guard password == confirmPassword else {
|
|
errorMessage = "Passwords do not match."
|
|
return
|
|
}
|
|
guard password.count >= 6 else {
|
|
errorMessage = "Password must be at least 6 characters."
|
|
return
|
|
}
|
|
|
|
isLoading = true
|
|
errorMessage = nil
|
|
|
|
Task {
|
|
do {
|
|
_ = try await AuthService.shared.signUpWithEmail(email, password: password)
|
|
dismiss()
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
isLoading = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Forgot Password
|
|
|
|
struct ForgotPasswordView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var email = ""
|
|
@State private var isLoading = false
|
|
@State private var message: String?
|
|
@State private var errorMessage: String?
|
|
|
|
var body: some View {
|
|
VStack(spacing: CloserSpacing.xxl) {
|
|
VStack(spacing: CloserSpacing.sm) {
|
|
Image(systemName: "lock.rotation")
|
|
.font(.system(size: 44))
|
|
.foregroundColor(.closerPrimary)
|
|
Text("Reset Password")
|
|
.font(CloserFont.title1)
|
|
.foregroundColor(.closerText)
|
|
Text("Enter your email and we'll send you a reset link")
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding(.top, CloserSpacing.xxl)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Email").font(CloserFont.footnote).foregroundColor(.closerTextSecondary)
|
|
TextField("you@example.com", text: $email)
|
|
.textContentType(.emailAddress)
|
|
.keyboardType(.emailAddress)
|
|
.autocapitalization(.none)
|
|
.disableAutocorrection(true)
|
|
.padding()
|
|
.background(Color.closerSurface)
|
|
.cornerRadius(CloserRadius.medium)
|
|
}
|
|
|
|
if let msg = message {
|
|
Text(msg)
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerSuccess)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
if let error = errorMessage {
|
|
Text(error)
|
|
.font(CloserFont.caption)
|
|
.foregroundColor(.closerDanger)
|
|
}
|
|
|
|
Button(action: sendResetEmail) {
|
|
if isLoading {
|
|
ProgressView().tint(.white)
|
|
} else {
|
|
Text("Send Reset Link")
|
|
}
|
|
}
|
|
.buttonStyle(PrimaryButtonStyle(isDisabled: isLoading))
|
|
.disabled(isLoading)
|
|
|
|
Spacer()
|
|
}
|
|
.closerPadding()
|
|
.background(Color.closerBackground)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
|
|
private func sendResetEmail() {
|
|
guard !email.isEmpty else {
|
|
errorMessage = "Please enter your email."
|
|
return
|
|
}
|
|
|
|
isLoading = true
|
|
errorMessage = nil
|
|
message = nil
|
|
|
|
Task {
|
|
do {
|
|
try await AuthService.shared.sendPasswordResetEmail(email)
|
|
message = "Reset link sent! Check your inbox."
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
isLoading = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Create Profile
|
|
|
|
struct CreateProfileView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
@State private var displayName = ""
|
|
@State private var sex = ""
|
|
@State private var isLoading = false
|
|
@State private var errorMessage: String?
|
|
|
|
let sexOptions = ["Male", "Female", "Non-binary", "Prefer not to say"]
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: CloserSpacing.xxl) {
|
|
VStack(spacing: CloserSpacing.sm) {
|
|
Image(systemName: "person.circle.fill")
|
|
.font(.system(size: 64))
|
|
.foregroundColor(.closerPrimary)
|
|
Text("Create Your Profile")
|
|
.font(CloserFont.title1)
|
|
.foregroundColor(.closerText)
|
|
Text("Let your partner know who you are")
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerTextSecondary)
|
|
}
|
|
.padding(.top, CloserSpacing.xxl)
|
|
|
|
VStack(spacing: CloserSpacing.lg) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Display Name").font(CloserFont.footnote).foregroundColor(.closerTextSecondary)
|
|
TextField("Your name", text: $displayName)
|
|
.padding()
|
|
.background(Color.closerSurface)
|
|
.cornerRadius(CloserRadius.medium)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Sex (optional)").font(CloserFont.footnote).foregroundColor(.closerTextSecondary)
|
|
Picker("Select", selection: $sex) {
|
|
Text("Select...").tag("")
|
|
ForEach(sexOptions, id: \.self) { option in
|
|
Text(option).tag(option)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.padding()
|
|
.background(Color.closerSurface)
|
|
.cornerRadius(CloserRadius.medium)
|
|
}
|
|
|
|
if let error = errorMessage {
|
|
Text(error)
|
|
.font(CloserFont.caption)
|
|
.foregroundColor(.closerDanger)
|
|
}
|
|
}
|
|
|
|
Button(action: saveProfile) {
|
|
if isLoading {
|
|
ProgressView().tint(.white)
|
|
} else {
|
|
Text("Continue")
|
|
}
|
|
}
|
|
.buttonStyle(PrimaryButtonStyle(isDisabled: isLoading || displayName.isEmpty))
|
|
.disabled(isLoading || displayName.isEmpty)
|
|
}
|
|
.closerPadding()
|
|
}
|
|
.background(Color.closerBackground)
|
|
}
|
|
|
|
private func saveProfile() {
|
|
guard !displayName.isEmpty else { return }
|
|
isLoading = true
|
|
|
|
Task {
|
|
do {
|
|
let userId = try FirestoreService.shared.userId()
|
|
let user = User(
|
|
id: userId,
|
|
email: AuthService.shared.currentUserEmail ?? "",
|
|
displayName: displayName,
|
|
photoUrl: "",
|
|
sex: sex,
|
|
partnerId: nil,
|
|
coupleId: nil,
|
|
plan: "free",
|
|
createdAt: Date(),
|
|
lastActiveAt: Date()
|
|
)
|
|
try await FirestoreService.shared.setDocument(user, at: FirestoreService.shared.userDocument(userId))
|
|
await appState.refreshData()
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
isLoading = false
|
|
}
|
|
}
|
|
}
|