From 0652908c203268609368df06991b25aef5504b91 Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 2 Dec 2025 11:00:51 -0600 Subject: [PATCH] Add iOS onboarding flow with residence creation and task templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add complete onboarding flow with 7 screens: Welcome, Name Residence, Value Props, Create Account, Verify Email, First Task, Subscription - Auto-create residence after email verification for "Start Fresh" users - Add predefined task templates (HVAC, Smoke Detectors, Lawn Care, Leaks) that create real tasks with today as due date - Add returning user login button on welcome screen - Update RootView to prioritize onboarding flow for first-time users - Use app icon asset instead of house.fill SF Symbol - Smooth slide transitions with fade-out for back navigation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Onboarding/OnboardingCoordinator.swift | 344 +++++++++++++++++ .../OnboardingCreateAccountView.swift | 348 ++++++++++++++++++ .../Onboarding/OnboardingFirstTaskView.swift | 320 ++++++++++++++++ .../OnboardingJoinResidenceView.swift | 202 ++++++++++ .../OnboardingNameResidenceView.swift | 154 ++++++++ .../iosApp/Onboarding/OnboardingState.swift | 132 +++++++ .../OnboardingSubscriptionView.swift | 236 ++++++++++++ .../Onboarding/OnboardingValuePropsView.swift | 174 +++++++++ .../OnboardingVerifyEmailView.swift | 192 ++++++++++ .../Onboarding/OnboardingWelcomeView.swift | 120 ++++++ iosApp/iosApp/RootView.swift | 23 +- .../VerifyEmail/VerifyEmailViewModel.swift | 3 + 12 files changed, 2245 insertions(+), 3 deletions(-) create mode 100644 iosApp/iosApp/Onboarding/OnboardingCoordinator.swift create mode 100644 iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift create mode 100644 iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift create mode 100644 iosApp/iosApp/Onboarding/OnboardingJoinResidenceView.swift create mode 100644 iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift create mode 100644 iosApp/iosApp/Onboarding/OnboardingState.swift create mode 100644 iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift create mode 100644 iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift create mode 100644 iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift create mode 100644 iosApp/iosApp/Onboarding/OnboardingWelcomeView.swift diff --git a/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift b/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift new file mode 100644 index 0000000..965eba9 --- /dev/null +++ b/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift @@ -0,0 +1,344 @@ +import SwiftUI +import ComposeApp + +/// Coordinates the onboarding flow, presenting the appropriate view based on current step +struct OnboardingCoordinator: View { + @StateObject private var onboardingState = OnboardingState.shared + @StateObject private var residenceViewModel = ResidenceViewModel() + @State private var showingRegister = false + @State private var showingLogin = false + @State private var isNavigatingBack = false + @State private var isCreatingResidence = false + + var onComplete: () -> Void + + /// Transition that respects navigation direction + private var navigationTransition: AnyTransition { + if isNavigatingBack { + // Going back: new view slides in from left, old view fades out in place + return .asymmetric( + insertion: .move(edge: .leading), + removal: .opacity + ) + } else { + // Going forward: new view slides in from right, old view fades out in place + return .asymmetric( + insertion: .move(edge: .trailing), + removal: .opacity + ) + } + } + + private func goBack(to step: OnboardingStep) { + withAnimation(.easeInOut(duration: 0.3)) { + isNavigatingBack = true + onboardingState.currentStep = step + } + // Reset after animation completes + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + isNavigatingBack = false + } + } + + private func goForward() { + withAnimation(.easeInOut(duration: 0.3)) { + isNavigatingBack = false + onboardingState.nextStep() + } + } + + private func goForward(to step: OnboardingStep) { + withAnimation(.easeInOut(duration: 0.3)) { + isNavigatingBack = false + onboardingState.currentStep = step + } + } + + /// Creates a residence with the pending name from onboarding, then calls completion + private func createResidenceIfNeeded(thenNavigateTo step: OnboardingStep) { + print("🏠 ONBOARDING: createResidenceIfNeeded called") + print("🏠 ONBOARDING: userIntent = \(onboardingState.userIntent)") + print("🏠 ONBOARDING: pendingResidenceName = '\(onboardingState.pendingResidenceName)'") + + // Only create residence if user is starting fresh (not joining existing) + guard onboardingState.userIntent == .startFresh, + !onboardingState.pendingResidenceName.isEmpty else { + print("🏠 ONBOARDING: Skipping residence creation - conditions not met") + goForward(to: step) + return + } + + print("🏠 ONBOARDING: Creating residence with name: \(onboardingState.pendingResidenceName)") + + isCreatingResidence = true + + let request = ResidenceCreateRequest( + name: onboardingState.pendingResidenceName, + propertyTypeId: nil, + streetAddress: nil, + apartmentUnit: nil, + city: nil, + stateProvince: nil, + postalCode: nil, + country: nil, + bedrooms: nil, + bathrooms: nil, + squareFootage: nil, + lotSize: nil, + yearBuilt: nil, + description: nil, + purchaseDate: nil, + purchasePrice: nil, + isPrimary: KotlinBoolean(bool: true) + ) + + residenceViewModel.createResidence(request: request) { success in + print("🏠 ONBOARDING: Residence creation result: \(success ? "SUCCESS" : "FAILED")") + self.isCreatingResidence = false + // Navigate regardless of success - user can create residence later if needed + self.goForward(to: step) + } + } + + /// Current step index for progress indicator (0-based) + private var currentProgressStep: Int { + switch onboardingState.currentStep { + case .welcome: return 0 + case .nameResidence: return 1 + case .valueProps: return 2 + case .createAccount: return 3 + case .verifyEmail: return 4 + case .joinResidence: return 4 + case .firstTask: return 4 + case .subscriptionUpsell: return 4 + } + } + + /// Whether to show the back button + private var showBackButton: Bool { + switch onboardingState.currentStep { + case .welcome, .joinResidence, .firstTask, .subscriptionUpsell: + return false + default: + return true + } + } + + /// Whether to show the skip button + private var showSkipButton: Bool { + switch onboardingState.currentStep { + case .valueProps, .joinResidence, .firstTask, .subscriptionUpsell: + return true + default: + return false + } + } + + /// Whether to show the progress indicator + private var showProgressIndicator: Bool { + switch onboardingState.currentStep { + case .welcome, .joinResidence, .firstTask, .subscriptionUpsell: + return false + default: + return true + } + } + + private func handleBack() { + switch onboardingState.currentStep { + case .nameResidence: + goBack(to: .welcome) + case .valueProps: + goBack(to: .nameResidence) + case .createAccount: + if onboardingState.userIntent == .joinExisting { + goBack(to: .welcome) + } else { + goBack(to: .valueProps) + } + case .verifyEmail: + AuthenticationManager.shared.logout() + goBack(to: .createAccount) + default: + break + } + } + + private func handleSkip() { + switch onboardingState.currentStep { + case .valueProps: + goForward() + case .joinResidence, .firstTask: + goForward() + case .subscriptionUpsell: + onboardingState.completeOnboarding() + onComplete() + default: + break + } + } + + var body: some View { + VStack(spacing: 0) { + // Shared navigation bar - stays static + HStack { + // Back button + Button(action: handleBack) { + Image(systemName: "chevron.left") + .font(.title2) + .foregroundColor(Color.appPrimary) + } + .opacity(showBackButton ? 1 : 0) + .disabled(!showBackButton) + + Spacer() + + // Progress indicator + if showProgressIndicator { + OnboardingProgressIndicator(currentStep: currentProgressStep, totalSteps: 5) + } + + Spacer() + + // Skip button + Button(action: handleSkip) { + Text("Skip") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(Color.appTextSecondary) + } + .opacity(showSkipButton ? 1 : 0) + .disabled(!showSkipButton) + } + .padding(.horizontal, AppSpacing.lg) + .padding(.vertical, AppSpacing.md) + + // Content area - this is what transitions + ZStack { + switch onboardingState.currentStep { + case .welcome: + OnboardingWelcomeContent( + onStartFresh: { + onboardingState.userIntent = .startFresh + goForward() + }, + onJoinExisting: { + onboardingState.userIntent = .joinExisting + goForward() + }, + onLogin: { + // Returning user logged in - mark onboarding complete and go to main view + onboardingState.completeOnboarding() + AuthenticationManager.shared.markVerified() + onComplete() + } + ) + .transition(navigationTransition) + + case .nameResidence: + OnboardingNameResidenceContent( + residenceName: $onboardingState.pendingResidenceName, + onContinue: { + goForward() + } + ) + .transition(navigationTransition) + + case .valueProps: + OnboardingValuePropsContent( + onContinue: { + goForward() + } + ) + .transition(navigationTransition) + + case .createAccount: + OnboardingCreateAccountContent( + onAccountCreated: { isVerified in + if isVerified { + // Skip email verification + if onboardingState.userIntent == .joinExisting { + goForward(to: .joinResidence) + } else { + createResidenceIfNeeded(thenNavigateTo: .firstTask) + } + } else { + goForward() + } + } + ) + .transition(navigationTransition) + + case .verifyEmail: + OnboardingVerifyEmailContent( + onVerified: { + print("🏠 ONBOARDING: onVerified callback triggered in coordinator") + // NOTE: Do NOT call markVerified() here - it would cause RootView + // to switch to MainTabView before onboarding completes. + // markVerified() is called at the end of onboarding in onComplete. + if onboardingState.userIntent == .joinExisting { + goForward(to: .joinResidence) + } else { + createResidenceIfNeeded(thenNavigateTo: .firstTask) + } + } + ) + .transition(navigationTransition) + + case .joinResidence: + OnboardingJoinResidenceContent( + onJoined: { + goForward() + } + ) + .transition(navigationTransition) + + case .firstTask: + OnboardingFirstTaskContent( + residenceName: onboardingState.pendingResidenceName, + onTaskAdded: { + goForward() + } + ) + .transition(navigationTransition) + + case .subscriptionUpsell: + OnboardingSubscriptionContent( + onSubscribe: { + // Handle subscription flow + onboardingState.completeOnboarding() + onComplete() + } + ) + .transition(navigationTransition) + } + } + .animation(.easeInOut(duration: 0.3), value: onboardingState.currentStep) + } + .background(Color.appBackgroundPrimary) + } +} + +// MARK: - Progress Indicator + +struct OnboardingProgressIndicator: View { + let currentStep: Int + let totalSteps: Int + + var body: some View { + HStack(spacing: AppSpacing.xs) { + ForEach(0.. Void // Bool indicates if user is already verified + + @StateObject private var viewModel = RegisterViewModel() + @StateObject private var appleSignInViewModel = AppleSignInViewModel() + @State private var showingLoginSheet = false + @State private var isExpanded = false + @FocusState private var focusedField: Field? + + enum Field { + case username, email, password, confirmPassword + } + + private var isFormValid: Bool { + !viewModel.username.isEmpty && + !viewModel.email.isEmpty && + !viewModel.password.isEmpty && + viewModel.password == viewModel.confirmPassword + } + + var body: some View { + ScrollView { + VStack(spacing: AppSpacing.xl) { + // Header + VStack(spacing: AppSpacing.sm) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 80, height: 80) + + Image(systemName: "person.badge.plus") + .font(.system(size: 36)) + .foregroundStyle(Color.appPrimary.gradient) + } + + Text("Save your home to your account") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(Color.appTextPrimary) + .multilineTextAlignment(.center) + + Text("Your data will be synced across devices") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + } + .padding(.top, AppSpacing.lg) + + // Sign in with Apple (Primary) + VStack(spacing: AppSpacing.md) { + SignInWithAppleButton( + onRequest: { request in + request.requestedScopes = [.fullName, .email] + }, + onCompletion: { _ in } + ) + .frame(height: 56) + .cornerRadius(AppRadius.md) + .signInWithAppleButtonStyle(.black) + .disabled(appleSignInViewModel.isLoading) + .opacity(appleSignInViewModel.isLoading ? 0.6 : 1.0) + .overlay { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + appleSignInViewModel.signInWithApple() + } + } + + if appleSignInViewModel.isLoading { + HStack { + ProgressView() + Text("Signing in with Apple...") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + } + } + + if let error = appleSignInViewModel.errorMessage { + errorMessage(error) + } + } + + // Divider + HStack { + Rectangle() + .fill(Color.appTextSecondary.opacity(0.3)) + .frame(height: 1) + Text("or") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + .padding(.horizontal, AppSpacing.sm) + Rectangle() + .fill(Color.appTextSecondary.opacity(0.3)) + .frame(height: 1) + } + + // Create Account Form + VStack(spacing: AppSpacing.md) { + if !isExpanded { + // Collapsed state + Button(action: { + withAnimation(.easeInOut(duration: 0.3)) { + isExpanded = true + } + }) { + HStack(spacing: AppSpacing.sm) { + Image(systemName: "envelope.fill") + .font(.title3) + Text("Create Account with Email") + .font(.headline) + .fontWeight(.medium) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appPrimary) + .background(Color.appPrimary.opacity(0.1)) + .cornerRadius(AppRadius.md) + } + } else { + // Expanded form + VStack(spacing: AppSpacing.md) { + // Username + formField( + icon: "person.fill", + placeholder: "Username", + text: $viewModel.username, + field: .username, + keyboardType: .default, + contentType: .username + ) + + // Email + formField( + icon: "envelope.fill", + placeholder: "Email", + text: $viewModel.email, + field: .email, + keyboardType: .emailAddress, + contentType: .emailAddress + ) + + // Password + secureFormField( + icon: "lock.fill", + placeholder: "Password", + text: $viewModel.password, + field: .password + ) + + // Confirm Password + secureFormField( + icon: "lock.fill", + placeholder: "Confirm Password", + text: $viewModel.confirmPassword, + field: .confirmPassword + ) + + if let error = viewModel.errorMessage { + errorMessage(error) + } + + // Register button + Button(action: { + viewModel.register() + }) { + HStack { + if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } + Text(viewModel.isLoading ? "Creating Account..." : "Create Account") + .font(.headline) + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + isFormValid && !viewModel.isLoading + ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing)) + : AnyShapeStyle(Color.appTextSecondary) + ) + .cornerRadius(AppRadius.md) + .shadow(color: isFormValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5) + } + .disabled(!isFormValid || viewModel.isLoading) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + + // Already have an account + HStack(spacing: AppSpacing.xs) { + Text("Already have an account?") + .font(.body) + .foregroundColor(Color.appTextSecondary) + + Button("Log in") { + showingLoginSheet = true + } + .font(.body) + .fontWeight(.semibold) + .foregroundColor(Color.appPrimary) + } + .padding(.top, AppSpacing.md) + } + .padding(.horizontal, AppSpacing.xl) + .padding(.bottom, AppSpacing.xxxl) + } + .background(Color.appBackgroundPrimary) + .sheet(isPresented: $showingLoginSheet) { + LoginView(onLoginSuccess: { + showingLoginSheet = false + onAccountCreated(true) + }) + } + .onChange(of: viewModel.isRegistered) { _, isRegistered in + if isRegistered { + // Registration successful - user is authenticated but not verified + onAccountCreated(false) + } + } + .onAppear { + // Set up Apple Sign In callback + appleSignInViewModel.onSignInSuccess = { isVerified in + AuthenticationManager.shared.login(verified: isVerified) + // Residence creation is handled by the coordinator + onAccountCreated(isVerified) + } + } + } + + // MARK: - Form Fields + + private func formField( + icon: String, + placeholder: String, + text: Binding, + field: Field, + keyboardType: UIKeyboardType, + contentType: UITextContentType + ) -> some View { + HStack(spacing: AppSpacing.sm) { + Image(systemName: icon) + .foregroundColor(Color.appTextSecondary) + .frame(width: 20) + + TextField(placeholder, text: text) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(keyboardType) + .textContentType(contentType) + .focused($focusedField, equals: field) + } + .padding(AppSpacing.md) + .background(Color.appBackgroundSecondary) + .cornerRadius(AppRadius.md) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.md) + .stroke(focusedField == field ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5) + ) + } + + private func secureFormField( + icon: String, + placeholder: String, + text: Binding, + field: Field + ) -> some View { + HStack(spacing: AppSpacing.sm) { + Image(systemName: icon) + .foregroundColor(Color.appTextSecondary) + .frame(width: 20) + + SecureField(placeholder, text: text) + .textContentType(.password) + .focused($focusedField, equals: field) + } + .padding(AppSpacing.md) + .background(Color.appBackgroundSecondary) + .cornerRadius(AppRadius.md) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.md) + .stroke(focusedField == field ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5) + ) + } + + private func errorMessage(_ message: String) -> some View { + HStack(spacing: AppSpacing.sm) { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(Color.appError) + Text(message) + .font(.callout) + .foregroundColor(Color.appError) + Spacer() + } + .padding(AppSpacing.md) + .background(Color.appError.opacity(0.1)) + .cornerRadius(AppRadius.md) + } +} + +// MARK: - Legacy wrapper with navigation bar (for backwards compatibility) + +struct OnboardingCreateAccountView: View { + var onAccountCreated: (Bool) -> Void + var onBack: () -> Void + + var body: some View { + VStack(spacing: 0) { + // Navigation bar + HStack { + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.title2) + .foregroundColor(Color.appPrimary) + } + + Spacer() + + OnboardingProgressIndicator(currentStep: 3, totalSteps: 5) + + Spacer() + + // Invisible spacer for alignment + Image(systemName: "chevron.left") + .font(.title2) + .opacity(0) + } + .padding(.horizontal, AppSpacing.lg) + .padding(.vertical, AppSpacing.md) + + OnboardingCreateAccountContent(onAccountCreated: onAccountCreated) + } + .background(Color.appBackgroundPrimary) + } +} + +// MARK: - Preview + +#Preview { + OnboardingCreateAccountContent(onAccountCreated: { _ in }) +} diff --git a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift new file mode 100644 index 0000000..0f0b748 --- /dev/null +++ b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift @@ -0,0 +1,320 @@ +import SwiftUI +import ComposeApp + +/// Screen 6: First task prompt with suggested templates - Content only (no navigation bar) +struct OnboardingFirstTaskContent: View { + var residenceName: String + var onTaskAdded: () -> Void + + @StateObject private var viewModel = TaskViewModel() + @State private var selectedTask: TaskTemplate? + @State private var isCreatingTask = false + @State private var showCustomTaskSheet = false + + private let taskTemplates: [TaskTemplate] = [ + TaskTemplate( + icon: "fanblades.fill", + title: "Change HVAC Filter", + category: "hvac", + frequency: "monthly", + color: Color.appPrimary + ), + TaskTemplate( + icon: "smoke.fill", + title: "Check Smoke Detectors", + category: "safety", + frequency: "semiannually", + color: Color.appError + ), + TaskTemplate( + icon: "leaf.fill", + title: "Lawn Care", + category: "landscaping", + frequency: "weekly", + color: Color(hex: "#4CAF50") ?? .green + ), + TaskTemplate( + icon: "drop.fill", + title: "Check for Leaks", + category: "plumbing", + frequency: "monthly", + color: Color.appSecondary + ) + ] + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: AppSpacing.xl) { + // Header + VStack(spacing: AppSpacing.sm) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 80, height: 80) + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 40)) + .foregroundStyle(Color.appPrimary.gradient) + } + + Text("Your home is ready!") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(Color.appTextPrimary) + + Text("What's the first thing you want to track?") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + } + .padding(.top, AppSpacing.lg) + + // Task templates grid + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: AppSpacing.md) { + ForEach(taskTemplates) { template in + TaskTemplateCard( + template: template, + isSelected: selectedTask?.id == template.id, + onTap: { + withAnimation(.easeInOut(duration: 0.2)) { + if selectedTask?.id == template.id { + selectedTask = nil + } else { + selectedTask = template + } + } + } + ) + } + } + .padding(.horizontal, AppSpacing.lg) + + // Custom task option + Button(action: { + showCustomTaskSheet = true + }) { + HStack(spacing: AppSpacing.sm) { + Image(systemName: "plus.circle.fill") + .font(.title2) + .foregroundColor(Color.appPrimary) + + Text("Add Custom Task") + .font(.headline) + .fontWeight(.medium) + .foregroundColor(Color.appPrimary) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .background(Color.appPrimary.opacity(0.1)) + .cornerRadius(AppRadius.md) + } + .padding(.horizontal, AppSpacing.lg) + } + .padding(.bottom, 120) // Space for button + } + + // Bottom action area + VStack(spacing: AppSpacing.md) { + if selectedTask != nil { + Button(action: addSelectedTask) { + HStack { + if isCreatingTask { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } + Text(isCreatingTask ? "Adding Task..." : "Add Task & Continue") + .font(.headline) + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + LinearGradient( + colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .cornerRadius(AppRadius.md) + .shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5) + } + .disabled(isCreatingTask) + } + } + .padding(.horizontal, AppSpacing.xl) + .padding(.bottom, AppSpacing.xl) + .background( + LinearGradient( + colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 40) + .offset(y: -40) + , alignment: .top + ) + } + .background(Color.appBackgroundPrimary) + .sheet(isPresented: $showCustomTaskSheet) { + // TODO: Show custom task form + Text("Custom Task Form") + } + } + + private func addSelectedTask() { + guard let template = selectedTask else { return } + + // Get the first residence from cache (just created during onboarding) + guard let residences = DataCache.shared.residences.value as? [ResidenceResponse], + let residence = residences.first else { + print("🏠 ONBOARDING: No residence found in cache, skipping task creation") + onTaskAdded() + return + } + + isCreatingTask = true + + // Look up category ID from DataCache + let categoryId: Int32? = { + guard let categories = DataCache.shared.taskCategories.value as? [TaskCategory] else { return nil } + // Map template category to actual category + let categoryName = template.category.lowercased() + return categories.first { $0.name.lowercased() == categoryName }?.id + }() + + // Look up frequency ID from DataCache + let frequencyId: Int32? = { + guard let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency] else { return nil } + let frequencyName = template.frequency.lowercased() + return frequencies.first { $0.name.lowercased() == frequencyName }?.id + }() + + // Format today's date as YYYY-MM-DD for the API + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let todayString = dateFormatter.string(from: Date()) + + print("🏠 ONBOARDING: Creating task '\(template.title)' for residence \(residence.id)") + print("🏠 ONBOARDING: categoryId=\(String(describing: categoryId)), frequencyId=\(String(describing: frequencyId)), dueDate=\(todayString)") + + let request = TaskCreateRequest( + residenceId: residence.id, + title: template.title, + description: nil, + categoryId: categoryId.map { KotlinInt(int: $0) }, + priorityId: nil, + statusId: nil, + frequencyId: frequencyId.map { KotlinInt(int: $0) }, + assignedToId: nil, + dueDate: todayString, + estimatedCost: nil, + contractorId: nil + ) + + viewModel.createTask(request: request) { success in + print("🏠 ONBOARDING: Task creation result: \(success ? "SUCCESS" : "FAILED")") + self.isCreatingTask = false + self.onTaskAdded() + } + } +} + +// MARK: - Task Template Model + +struct TaskTemplate: Identifiable { + let id = UUID() + let icon: String + let title: String + let category: String + let frequency: String + let color: Color +} + +// MARK: - Task Template Card + +struct TaskTemplateCard: View { + let template: TaskTemplate + let isSelected: Bool + var onTap: () -> Void + + var body: some View { + Button(action: onTap) { + VStack(spacing: AppSpacing.sm) { + ZStack { + Circle() + .fill(template.color.opacity(0.1)) + .frame(width: 56, height: 56) + + Image(systemName: template.icon) + .font(.title2) + .foregroundColor(template.color) + } + + Text(template.title) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(Color.appTextPrimary) + .multilineTextAlignment(.center) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + + Text(template.frequency.capitalized) + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } + .frame(maxWidth: .infinity) + .padding(AppSpacing.md) + .background(Color.appBackgroundSecondary) + .cornerRadius(AppRadius.lg) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.lg) + .stroke(isSelected ? template.color : Color.clear, lineWidth: 2) + ) + .shadow(color: isSelected ? template.color.opacity(0.2) : .clear, radius: 8) + } + .buttonStyle(.plain) + } +} + +// MARK: - Preview + +// MARK: - Legacy wrapper with navigation bar (for backwards compatibility) + +struct OnboardingFirstTaskView: View { + var residenceName: String + var onTaskAdded: () -> Void + var onSkip: () -> Void + + var body: some View { + VStack(spacing: 0) { + // Navigation bar + HStack { + Spacer() + + Button(action: onSkip) { + Text("Skip") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(Color.appTextSecondary) + } + } + .padding(.horizontal, AppSpacing.lg) + .padding(.vertical, AppSpacing.md) + + OnboardingFirstTaskContent( + residenceName: residenceName, + onTaskAdded: onTaskAdded + ) + } + .background(Color.appBackgroundPrimary) + } +} + +#Preview { + OnboardingFirstTaskContent( + residenceName: "My Home", + onTaskAdded: {} + ) +} diff --git a/iosApp/iosApp/Onboarding/OnboardingJoinResidenceView.swift b/iosApp/iosApp/Onboarding/OnboardingJoinResidenceView.swift new file mode 100644 index 0000000..85a5eb6 --- /dev/null +++ b/iosApp/iosApp/Onboarding/OnboardingJoinResidenceView.swift @@ -0,0 +1,202 @@ +import SwiftUI +import ComposeApp + +/// Screen for joining an existing residence with a share code - Content only (no navigation bar) +struct OnboardingJoinResidenceContent: View { + var onJoined: () -> Void + + @StateObject private var viewModel = ResidenceViewModel() + @State private var shareCode: String = "" + @State private var isLoading = false + @State private var errorMessage: String? + @FocusState private var isCodeFieldFocused: Bool + + private var isCodeValid: Bool { + shareCode.count == 6 + } + + var body: some View { + VStack(spacing: 0) { + Spacer() + + // Content + VStack(spacing: AppSpacing.xl) { + // Icon + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 100, height: 100) + + Image(systemName: "person.2.badge.key.fill") + .font(.system(size: 44)) + .foregroundStyle(Color.appPrimary.gradient) + } + + // Title + VStack(spacing: AppSpacing.sm) { + Text("Join a Residence") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(Color.appTextPrimary) + + Text("Enter the 6-character code shared with you to join an existing home.") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + } + + // Code input + VStack(alignment: .leading, spacing: AppSpacing.xs) { + HStack(spacing: AppSpacing.sm) { + Image(systemName: "key.fill") + .foregroundColor(Color.appTextSecondary) + .frame(width: 20) + + TextField("Enter share code", text: $shareCode) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .focused($isCodeFieldFocused) + .onChange(of: shareCode) { _, newValue in + // Limit to 6 characters + if newValue.count > 6 { + shareCode = String(newValue.prefix(6)) + } + // Clear error when typing + errorMessage = nil + } + } + .padding(AppSpacing.md) + .background(Color.appBackgroundSecondary) + .cornerRadius(AppRadius.md) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.md) + .stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5) + ) + } + .padding(.horizontal, AppSpacing.xl) + + // Error message + if let error = errorMessage { + HStack(spacing: AppSpacing.sm) { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(Color.appError) + Text(error) + .font(.callout) + .foregroundColor(Color.appError) + Spacer() + } + .padding(AppSpacing.md) + .background(Color.appError.opacity(0.1)) + .cornerRadius(AppRadius.md) + .padding(.horizontal, AppSpacing.xl) + } + + // Loading indicator + if isLoading { + HStack { + ProgressView() + Text("Joining residence...") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + } + } + } + + Spacer() + + // Join button + Button(action: joinResidence) { + Text("Join Residence") + .font(.headline) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + isCodeValid && !isLoading + ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing)) + : AnyShapeStyle(Color.appTextSecondary) + ) + .cornerRadius(AppRadius.md) + .shadow(color: isCodeValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5) + } + .disabled(!isCodeValid || isLoading) + .padding(.horizontal, AppSpacing.xl) + .padding(.bottom, AppSpacing.xxxl) + } + .background(Color.appBackgroundPrimary) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + isCodeFieldFocused = true + } + } + } + + private func joinResidence() { + guard shareCode.count == 6 else { + errorMessage = "Share code must be 6 characters" + return + } + + isLoading = true + errorMessage = nil + + Task { + // Call the shared ViewModel which uses APILayer + await viewModel.sharedViewModel.joinWithCode(code: shareCode) + + // Observe the result + for await state in viewModel.sharedViewModel.joinResidenceState { + if state is ApiResultSuccess { + await MainActor.run { + viewModel.sharedViewModel.resetJoinResidenceState() + isLoading = false + onJoined() + } + break + } else if let error = state as? ApiResultError { + await MainActor.run { + errorMessage = ErrorMessageParser.parse(error.message) + viewModel.sharedViewModel.resetJoinResidenceState() + isLoading = false + } + break + } + } + } + } +} + +// MARK: - Preview + +// MARK: - Legacy wrapper with navigation bar (for backwards compatibility) + +struct OnboardingJoinResidenceView: View { + var onJoined: () -> Void + var onSkip: () -> Void + + var body: some View { + VStack(spacing: 0) { + // Navigation bar + HStack { + Spacer() + + Button(action: onSkip) { + Text("Skip") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(Color.appTextSecondary) + } + } + .padding(.horizontal, AppSpacing.lg) + .padding(.vertical, AppSpacing.md) + + OnboardingJoinResidenceContent(onJoined: onJoined) + } + .background(Color.appBackgroundPrimary) + } +} + +#Preview { + OnboardingJoinResidenceContent(onJoined: {}) +} diff --git a/iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift b/iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift new file mode 100644 index 0000000..bd9590b --- /dev/null +++ b/iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift @@ -0,0 +1,154 @@ +import SwiftUI + +/// Screen 2: Name your home - Content only (no navigation bar) +struct OnboardingNameResidenceContent: View { + @Binding var residenceName: String + var onContinue: () -> Void + + @FocusState private var isTextFieldFocused: Bool + + private var isValid: Bool { + !residenceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + VStack(spacing: 0) { + Spacer() + + // Content + VStack(spacing: AppSpacing.xl) { + // Icon + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 100, height: 100) + + Image("icon") + .resizable() + .scaledToFit() + .frame(width: 50, height: 50) + } + + // Title + VStack(spacing: AppSpacing.sm) { + Text("What should we call your place?") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(Color.appTextPrimary) + .multilineTextAlignment(.center) + + Text("You can always change this later") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + } + + // Text field + VStack(alignment: .leading, spacing: AppSpacing.xs) { + HStack(spacing: AppSpacing.sm) { + Image(systemName: "pencil") + .foregroundColor(Color.appTextSecondary) + .frame(width: 20) + + TextField("My Home", text: $residenceName) + .font(.body) + .textInputAutocapitalization(.words) + .focused($isTextFieldFocused) + .submitLabel(.continue) + .onSubmit { + if isValid { + onContinue() + } + } + } + .padding(AppSpacing.md) + .background(Color.appBackgroundSecondary) + .cornerRadius(AppRadius.md) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.md) + .stroke(isTextFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5) + ) + .shadow(color: isTextFieldFocused ? Color.appPrimary.opacity(0.1) : .clear, radius: 8) + .animation(.easeInOut(duration: 0.2), value: isTextFieldFocused) + } + .padding(.horizontal, AppSpacing.xl) + } + + Spacer() + + // Continue button + Button(action: onContinue) { + Text("Continue") + .font(.headline) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + isValid + ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing)) + : AnyShapeStyle(Color.appTextSecondary) + ) + .cornerRadius(AppRadius.md) + .shadow(color: isValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5) + } + .disabled(!isValid) + .padding(.horizontal, AppSpacing.xl) + .padding(.bottom, AppSpacing.xxxl) + } + .background(Color.appBackgroundPrimary) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + isTextFieldFocused = true + } + } + } +} + +// MARK: - Legacy wrapper with navigation bar (for backwards compatibility) + +struct OnboardingNameResidenceView: View { + @Binding var residenceName: String + var onContinue: () -> Void + var onBack: () -> Void + + var body: some View { + VStack(spacing: 0) { + // Navigation bar + HStack { + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.title2) + .foregroundColor(Color.appPrimary) + } + + Spacer() + + OnboardingProgressIndicator(currentStep: 1, totalSteps: 5) + + Spacer() + + // Invisible spacer for alignment + Image(systemName: "chevron.left") + .font(.title2) + .opacity(0) + } + .padding(.horizontal, AppSpacing.lg) + .padding(.vertical, AppSpacing.md) + + OnboardingNameResidenceContent( + residenceName: $residenceName, + onContinue: onContinue + ) + } + .background(Color.appBackgroundPrimary) + } +} + +// MARK: - Preview + +#Preview { + OnboardingNameResidenceContent( + residenceName: .constant(""), + onContinue: {} + ) +} diff --git a/iosApp/iosApp/Onboarding/OnboardingState.swift b/iosApp/iosApp/Onboarding/OnboardingState.swift new file mode 100644 index 0000000..e508dfa --- /dev/null +++ b/iosApp/iosApp/Onboarding/OnboardingState.swift @@ -0,0 +1,132 @@ +import SwiftUI +import ComposeApp + +/// User's intent during onboarding +enum OnboardingIntent: String { + case unknown + case startFresh // Creating a new residence + case joinExisting // Joining with a share code +} + +/// Manages the state of the onboarding flow +class OnboardingState: ObservableObject { + static let shared = OnboardingState() + + /// Whether the user has completed onboarding + @AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding: Bool = false + + /// The name of the residence being created during onboarding + @AppStorage("onboardingResidenceName") var pendingResidenceName: String = "" + + /// The user's selected intent (start fresh or join existing) - persisted + @AppStorage("onboardingUserIntent") private var userIntentRaw: String = OnboardingIntent.unknown.rawValue + + /// The user's selected intent (start fresh or join existing) + var userIntent: OnboardingIntent { + get { OnboardingIntent(rawValue: userIntentRaw) ?? .unknown } + set { + userIntentRaw = newValue.rawValue + objectWillChange.send() + } + } + + /// Current step in the onboarding flow + @Published var currentStep: OnboardingStep = .welcome + + /// Whether onboarding is currently active + @Published var isOnboardingActive: Bool = false + + private init() {} + + /// Start the onboarding flow + func startOnboarding() { + isOnboardingActive = true + currentStep = .welcome + userIntent = .unknown + } + + /// Move to the next step in the flow + func nextStep() { + switch currentStep { + case .welcome: + if userIntent == .joinExisting { + currentStep = .createAccount + } else { + currentStep = .nameResidence + } + case .nameResidence: + currentStep = .valueProps + case .valueProps: + currentStep = .createAccount + case .createAccount: + currentStep = .verifyEmail + case .verifyEmail: + if userIntent == .joinExisting { + currentStep = .joinResidence + } else { + currentStep = .firstTask + } + case .joinResidence: + currentStep = .subscriptionUpsell + case .firstTask: + currentStep = .subscriptionUpsell + case .subscriptionUpsell: + completeOnboarding() + } + } + + /// Skip to a specific step + func skipTo(_ step: OnboardingStep) { + currentStep = step + } + + /// Complete the onboarding flow + func completeOnboarding() { + hasCompletedOnboarding = true + isOnboardingActive = false + pendingResidenceName = "" + userIntent = .unknown + } + + /// Reset onboarding state (useful for testing or re-onboarding) + func reset() { + hasCompletedOnboarding = false + isOnboardingActive = false + pendingResidenceName = "" + userIntent = .unknown + currentStep = .welcome + } +} + +/// Steps in the onboarding flow +enum OnboardingStep: Int, CaseIterable { + case welcome = 0 + case nameResidence = 1 + case valueProps = 2 + case createAccount = 3 + case verifyEmail = 4 + case joinResidence = 5 // Only for users joining with a code + case firstTask = 6 + case subscriptionUpsell = 7 + + var title: String { + switch self { + case .welcome: + return "Welcome" + case .nameResidence: + return "Name Your Home" + case .valueProps: + return "Features" + case .createAccount: + return "Create Account" + case .verifyEmail: + return "Verify Email" + case .joinResidence: + return "Join Residence" + case .firstTask: + return "First Task" + case .subscriptionUpsell: + return "Go Pro" + } + } +} diff --git a/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift b/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift new file mode 100644 index 0000000..5468d4c --- /dev/null +++ b/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift @@ -0,0 +1,236 @@ +import SwiftUI +import StoreKit + +/// Screen 7: Subscription upsell - Content only (no navigation bar) +struct OnboardingSubscriptionContent: View { + var onSubscribe: () -> Void + + @State private var isLoading = false + + private let benefits: [SubscriptionBenefit] = [ + SubscriptionBenefit( + icon: "building.2.fill", + title: "Unlimited Properties", + description: "Manage multiple homes, rentals, or vacation properties" + ), + SubscriptionBenefit( + icon: "checklist", + title: "Unlimited Tasks", + description: "Track as many maintenance items as you need" + ), + SubscriptionBenefit( + icon: "doc.fill", + title: "Warranty Storage", + description: "Keep all your warranty documents in one place" + ), + SubscriptionBenefit( + icon: "person.2.fill", + title: "Household Sharing", + description: "Invite family members to collaborate on tasks" + ) + ] + + var body: some View { + ScrollView { + VStack(spacing: AppSpacing.xl) { + // Header + VStack(spacing: AppSpacing.md) { + // Pro badge + HStack(spacing: AppSpacing.xs) { + Image(systemName: "star.fill") + .foregroundColor(Color.appAccent) + Text("PRO") + .font(.headline) + .fontWeight(.bold) + .foregroundColor(Color.appAccent) + } + .padding(.horizontal, AppSpacing.md) + .padding(.vertical, AppSpacing.xs) + .background(Color.appAccent.opacity(0.15)) + .clipShape(Capsule()) + + Text("Unlock the full power of Casera") + .font(.title) + .fontWeight(.bold) + .foregroundColor(Color.appTextPrimary) + .multilineTextAlignment(.center) + + Text("Get more done with Pro features") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + } + .padding(.top, AppSpacing.xxl) + + // Benefits list + VStack(spacing: AppSpacing.md) { + ForEach(benefits) { benefit in + SubscriptionBenefitRow(benefit: benefit) + } + } + .padding(.horizontal, AppSpacing.lg) + + // Pricing card + VStack(spacing: AppSpacing.md) { + HStack { + VStack(alignment: .leading, spacing: AppSpacing.xxs) { + Text("Monthly") + .font(.headline) + .foregroundColor(Color.appTextPrimary) + Text("Cancel anytime") + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: AppSpacing.xxs) { + Text("$4.99") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(Color.appPrimary) + Text("/month") + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } + } + .padding(AppSpacing.lg) + .background(Color.appBackgroundSecondary) + .cornerRadius(AppRadius.lg) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.lg) + .stroke(Color.appPrimary, lineWidth: 2) + ) + } + .padding(.horizontal, AppSpacing.lg) + + // CTA buttons + VStack(spacing: AppSpacing.md) { + Button(action: startFreeTrial) { + HStack { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } + Text("Start Free Trial") + .font(.headline) + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + LinearGradient( + colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .cornerRadius(AppRadius.md) + .shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5) + } + .disabled(isLoading) + + // Legal text + Text("7-day free trial, then $4.99/month. Cancel anytime in Settings.") + .font(.caption) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .padding(.top, AppSpacing.xs) + } + .padding(.horizontal, AppSpacing.xl) + .padding(.bottom, AppSpacing.xxxl) + } + } + .background(Color.appBackgroundPrimary) + } + + private func startFreeTrial() { + isLoading = true + + // Initiate StoreKit purchase flow + Task { + do { + // This would integrate with your StoreKitManager + // For now, we'll simulate the flow + try await Task.sleep(nanoseconds: 1_500_000_000) + + await MainActor.run { + isLoading = false + onSubscribe() + } + } catch { + await MainActor.run { + isLoading = false + // Handle error - still proceed (they can subscribe later) + onSubscribe() + } + } + } + } +} + +// MARK: - Legacy wrapper with navigation bar (for backwards compatibility) + +struct OnboardingSubscriptionView: View { + var onSubscribe: () -> Void + var onSkip: () -> Void + + var body: some View { + OnboardingSubscriptionContent(onSubscribe: onSubscribe) + } +} + +// MARK: - Subscription Benefit Model + +struct SubscriptionBenefit: Identifiable { + let id = UUID() + let icon: String + let title: String + let description: String +} + +// MARK: - Subscription Benefit Row + +struct SubscriptionBenefitRow: View { + let benefit: SubscriptionBenefit + + var body: some View { + HStack(spacing: AppSpacing.md) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 44, height: 44) + + Image(systemName: benefit.icon) + .font(.title3) + .foregroundColor(Color.appPrimary) + } + + VStack(alignment: .leading, spacing: AppSpacing.xxs) { + Text(benefit.title) + .font(.headline) + .foregroundColor(Color.appTextPrimary) + + Text(benefit.description) + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + .lineLimit(2) + } + + Spacer() + + Image(systemName: "checkmark.circle.fill") + .font(.title2) + .foregroundColor(Color.appPrimary) + } + .padding(AppSpacing.md) + .background(Color.appBackgroundSecondary) + .cornerRadius(AppRadius.lg) + } +} + +// MARK: - Preview + +#Preview { + OnboardingSubscriptionContent(onSubscribe: {}) +} diff --git a/iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift b/iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift new file mode 100644 index 0000000..73ece9f --- /dev/null +++ b/iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift @@ -0,0 +1,174 @@ +import SwiftUI + +/// Screen 3: Swipeable value propositions carousel - Content only (no navigation bar) +struct OnboardingValuePropsContent: View { + var onContinue: () -> Void + + @State private var currentPage = 0 + + private let valueProps: [ValueProp] = [ + ValueProp( + icon: "checklist", + title: "Track Maintenance Tasks", + description: "Never forget when the furnace filter is due. Set one-time or recurring tasks and get reminders.", + color: Color.appPrimary + ), + ValueProp( + icon: "person.text.rectangle", + title: "Save Contractor Info", + description: "Keep your trusted pros in one place. No more digging for business cards or phone numbers.", + color: Color.appSecondary + ), + ValueProp( + icon: "person.2.fill", + title: "Share with Family", + description: "Get the whole household on the same page. Everyone can see what's due and mark tasks complete.", + color: Color.appAccent + ) + ] + + var body: some View { + VStack(spacing: 0) { + Spacer() + + // Carousel + TabView(selection: $currentPage) { + ForEach(Array(valueProps.enumerated()), id: \.offset) { index, prop in + ValuePropCard(prop: prop) + .tag(index) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(height: 400) + + // Page indicator + HStack(spacing: AppSpacing.xs) { + ForEach(0.. Void + var onSkip: () -> Void + var onBack: () -> Void + + var body: some View { + VStack(spacing: 0) { + // Navigation bar + HStack { + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.title2) + .foregroundColor(Color.appPrimary) + } + + Spacer() + + OnboardingProgressIndicator(currentStep: 2, totalSteps: 5) + + Spacer() + + Button(action: onSkip) { + Text("Skip") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(Color.appTextSecondary) + } + } + .padding(.horizontal, AppSpacing.lg) + .padding(.vertical, AppSpacing.md) + + OnboardingValuePropsContent(onContinue: onContinue) + } + .background(Color.appBackgroundPrimary) + } +} + +// MARK: - Preview + +#Preview { + OnboardingValuePropsContent(onContinue: {}) +} diff --git a/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift b/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift new file mode 100644 index 0000000..5871adc --- /dev/null +++ b/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift @@ -0,0 +1,192 @@ +import SwiftUI +import ComposeApp + +/// Screen 5: Email verification during onboarding - Content only (no navigation bar) +struct OnboardingVerifyEmailContent: View { + var onVerified: () -> Void + + @StateObject private var viewModel = VerifyEmailViewModel() + @FocusState private var isCodeFieldFocused: Bool + @State private var hasCalledOnVerified = false + + var body: some View { + VStack(spacing: 0) { + Spacer() + + // Content + VStack(spacing: AppSpacing.xl) { + // Icon + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 100, height: 100) + + Image(systemName: "envelope.badge.fill") + .font(.system(size: 44)) + .foregroundStyle(Color.appPrimary.gradient) + } + + // Title + VStack(spacing: AppSpacing.sm) { + Text("Verify your email") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(Color.appTextPrimary) + + Text("We sent a 6-digit code to your email address. Enter it below to verify your account.") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + } + + // Code input + VStack(alignment: .leading, spacing: AppSpacing.xs) { + HStack(spacing: AppSpacing.sm) { + Image(systemName: "key.fill") + .foregroundColor(Color.appTextSecondary) + .frame(width: 20) + + TextField("Enter 6-digit code", text: $viewModel.code) + .keyboardType(.numberPad) + .textContentType(.oneTimeCode) + .focused($isCodeFieldFocused) + .onChange(of: viewModel.code) { _, newValue in + // Limit to 6 digits + if newValue.count > 6 { + viewModel.code = String(newValue.prefix(6)) + } + // Auto-verify when 6 digits entered + if newValue.count == 6 { + viewModel.verifyEmail() + } + } + } + .padding(AppSpacing.md) + .background(Color.appBackgroundSecondary) + .cornerRadius(AppRadius.md) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.md) + .stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5) + ) + } + .padding(.horizontal, AppSpacing.xl) + + // Error message + if let error = viewModel.errorMessage { + HStack(spacing: AppSpacing.sm) { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(Color.appError) + Text(error) + .font(.callout) + .foregroundColor(Color.appError) + Spacer() + } + .padding(AppSpacing.md) + .background(Color.appError.opacity(0.1)) + .cornerRadius(AppRadius.md) + .padding(.horizontal, AppSpacing.xl) + } + + // Loading indicator + if viewModel.isLoading { + HStack { + ProgressView() + Text("Verifying...") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + } + } + + // Resend code hint + Text("Didn't receive a code? Check your spam folder or re-register") + .font(.caption) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + } + + Spacer() + + // Verify button + Button(action: { + viewModel.verifyEmail() + }) { + Text("Verify") + .font(.headline) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + viewModel.code.count == 6 && !viewModel.isLoading + ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing)) + : AnyShapeStyle(Color.appTextSecondary) + ) + .cornerRadius(AppRadius.md) + .shadow(color: viewModel.code.count == 6 ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5) + } + .disabled(viewModel.code.count != 6 || viewModel.isLoading) + .padding(.horizontal, AppSpacing.xl) + .padding(.bottom, AppSpacing.xxxl) + } + .background(Color.appBackgroundPrimary) + .onAppear { + print("🏠 ONBOARDING: OnboardingVerifyEmailContent appeared") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + isCodeFieldFocused = true + } + } + .onReceive(viewModel.$isVerified) { isVerified in + print("🏠 ONBOARDING: onReceive isVerified = \(isVerified), hasCalledOnVerified = \(hasCalledOnVerified)") + if isVerified && !hasCalledOnVerified { + hasCalledOnVerified = true + print("🏠 ONBOARDING: Calling onVerified callback FIRST (before markVerified)") + // CRITICAL: Call onVerified FIRST so coordinator can create residence + // BEFORE markVerified changes auth state and disposes this view + onVerified() + } + } + } +} + +// MARK: - Legacy wrapper with navigation bar (for backwards compatibility) + +struct OnboardingVerifyEmailView: View { + var onVerified: () -> Void + var onLogout: () -> Void + + var body: some View { + VStack(spacing: 0) { + // Navigation bar + HStack { + // Logout option + Button(action: onLogout) { + Text("Back") + .font(.subheadline) + .foregroundColor(Color.appPrimary) + } + + Spacer() + + OnboardingProgressIndicator(currentStep: 4, totalSteps: 5) + + Spacer() + + // Invisible spacer for alignment + Text("Back") + .font(.subheadline) + .opacity(0) + } + .padding(.horizontal, AppSpacing.lg) + .padding(.vertical, AppSpacing.md) + + OnboardingVerifyEmailContent(onVerified: onVerified) + } + .background(Color.appBackgroundPrimary) + } +} + +// MARK: - Preview + +#Preview { + OnboardingVerifyEmailContent(onVerified: {}) +} diff --git a/iosApp/iosApp/Onboarding/OnboardingWelcomeView.swift b/iosApp/iosApp/Onboarding/OnboardingWelcomeView.swift new file mode 100644 index 0000000..27dafcf --- /dev/null +++ b/iosApp/iosApp/Onboarding/OnboardingWelcomeView.swift @@ -0,0 +1,120 @@ +import SwiftUI + +/// Screen 1: Welcome screen with Start Fresh / Join Existing options +struct OnboardingWelcomeView: View { + var onStartFresh: () -> Void + var onJoinExisting: () -> Void + var onLogin: () -> Void + + @State private var showingLoginSheet = false + + var body: some View { + VStack(spacing: 0) { + Spacer() + + // Hero section + VStack(spacing: AppSpacing.xl) { + // App icon + Image("icon") + .resizable() + .scaledToFit() + .frame(width: 120, height: 120) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.xxl)) + .shadow(color: Color.appPrimary.opacity(0.3), radius: 20, y: 10) + + // Welcome text + VStack(spacing: AppSpacing.sm) { + Text("Welcome to Casera") + .font(.largeTitle) + .fontWeight(.bold) + .foregroundColor(Color.appTextPrimary) + + Text("Your home maintenance companion") + .font(.title3) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + } + } + + Spacer() + + // Action buttons + VStack(spacing: AppSpacing.md) { + // Primary CTA - Start Fresh + Button(action: onStartFresh) { + HStack(spacing: AppSpacing.sm) { + Image("icon") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + Text("Start Fresh") + .font(.headline) + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + LinearGradient( + colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .cornerRadius(AppRadius.md) + .shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5) + } + + // Secondary CTA - Join Existing + Button(action: onJoinExisting) { + HStack(spacing: AppSpacing.sm) { + Image(systemName: "person.2.fill") + .font(.title3) + Text("I have a code to join") + .font(.headline) + .fontWeight(.medium) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appPrimary) + .background(Color.appPrimary.opacity(0.1)) + .cornerRadius(AppRadius.md) + } + + // Returning user login + Button(action: { + showingLoginSheet = true + }) { + Text("Already have an account? Log in") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + } + .padding(.top, AppSpacing.sm) + } + .padding(.horizontal, AppSpacing.xl) + .padding(.bottom, AppSpacing.xxxl) + } + .background(Color.appBackgroundPrimary) + .sheet(isPresented: $showingLoginSheet) { + LoginView(onLoginSuccess: { + showingLoginSheet = false + onLogin() + }) + } + } +} + +// MARK: - Content-only version (no navigation bar) + +/// Content-only version for use in coordinator +typealias OnboardingWelcomeContent = OnboardingWelcomeView + +// MARK: - Preview + +#Preview { + OnboardingWelcomeView( + onStartFresh: {}, + onJoinExisting: {}, + onLogin: {} + ) +} diff --git a/iosApp/iosApp/RootView.swift b/iosApp/iosApp/RootView.swift index 5a380f5..e02262e 100644 --- a/iosApp/iosApp/RootView.swift +++ b/iosApp/iosApp/RootView.swift @@ -104,14 +104,23 @@ class AuthenticationManager: ObservableObject { isAuthenticated = false isVerified = false + // Note: We don't reset onboarding state on logout + // so returning users go to login screen, not onboarding + print("AuthenticationManager: Logged out - all state reset") } + + /// Reset onboarding state (for testing or re-onboarding) + func resetOnboarding() { + OnboardingState.shared.reset() + } } -/// Root view that handles authentication flow: loading -> login -> verify email -> main app +/// Root view that handles authentication flow: loading -> onboarding -> login -> verify email -> main app struct RootView: View { @EnvironmentObject private var themeManager: ThemeManager @StateObject private var authManager = AuthenticationManager.shared + @StateObject private var onboardingState = OnboardingState.shared @State private var refreshID = UUID() var body: some View { @@ -119,11 +128,19 @@ struct RootView: View { if authManager.isCheckingAuth { // Show loading while checking auth status loadingView + } else if !onboardingState.hasCompletedOnboarding { + // Show onboarding for first-time users (includes auth + verification steps) + // This takes precedence because we need to finish the onboarding flow + OnboardingCoordinator(onComplete: { + // Onboarding complete - mark verified and refresh the view + authManager.markVerified() + refreshID = UUID() + }) } else if !authManager.isAuthenticated { - // Show login screen + // Show login screen for returning users LoginView() } else if !authManager.isVerified { - // Show email verification screen + // Show email verification screen (for returning users who haven't verified) VerifyEmailView( onVerifySuccess: { authManager.markVerified() diff --git a/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift b/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift index 3c0acfa..4fd3d1c 100644 --- a/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift +++ b/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift @@ -45,9 +45,12 @@ class VerifyEmailViewModel: ObservableObject { sharedViewModel.verifyEmailState, onLoading: { [weak self] in self?.isLoading = true }, onSuccess: { [weak self] (response: VerifyEmailResponse) in + print("🏠 VerifyEmailViewModel: onSuccess called, verified=\(response.verified)") if response.verified { + print("🏠 VerifyEmailViewModel: Setting isVerified = true") self?.isVerified = true self?.isLoading = false + print("🏠 VerifyEmailViewModel: isVerified is now \(self?.isVerified ?? false)") } else { self?.errorMessage = "Verification failed" self?.isLoading = false