Closer/iphone/Closer/Onboarding/OnboardingViews.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
}
}
}