From d4b5da71b73911dec5f404d0ede8ded7270d5d35 Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 2 Dec 2025 11:49:55 -0600 Subject: [PATCH] Redesign iOS onboarding UI with improved visuals and engagement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reorder flow: Welcome → Features → Name Residence → Account → Tasks → Upsell - Features screen: 4 animated cards with stats, gradient icons, playful copy - Name residence: Add name suggestions, gradient borders, encouraging text - Task templates: Multi-select with 24 tasks in 6 categories, accordion UI - Subscription upsell: Plan selection, animated crown, social proof, benefits 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Onboarding/OnboardingCoordinator.swift | 14 +- .../Onboarding/OnboardingFirstTaskView.swift | 615 +++++++++++++----- .../OnboardingNameResidenceView.swift | 162 ++++- .../iosApp/Onboarding/OnboardingState.swift | 7 +- .../OnboardingSubscriptionView.swift | 372 ++++++++--- .../Onboarding/OnboardingValuePropsView.swift | 240 +++++-- 6 files changed, 1050 insertions(+), 360 deletions(-) diff --git a/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift b/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift index 965eba9..ad8d829 100644 --- a/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift +++ b/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift @@ -101,11 +101,12 @@ struct OnboardingCoordinator: View { } /// Current step index for progress indicator (0-based) + /// Flow: Welcome → Features → Name Residence → Create Account → Verify → Tasks → Upsell private var currentProgressStep: Int { switch onboardingState.currentStep { case .welcome: return 0 - case .nameResidence: return 1 - case .valueProps: return 2 + case .valueProps: return 1 + case .nameResidence: return 2 case .createAccount: return 3 case .verifyEmail: return 4 case .joinResidence: return 4 @@ -145,16 +146,17 @@ struct OnboardingCoordinator: View { } private func handleBack() { + // Flow: Welcome → Features → Name Residence → Create Account → Verify → Tasks → Upsell switch onboardingState.currentStep { - case .nameResidence: - goBack(to: .welcome) case .valueProps: - goBack(to: .nameResidence) + goBack(to: .welcome) + case .nameResidence: + goBack(to: .valueProps) case .createAccount: if onboardingState.userIntent == .joinExisting { goBack(to: .welcome) } else { - goBack(to: .valueProps) + goBack(to: .nameResidence) } case .verifyEmail: AuthenticationManager.shared.logout() diff --git a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift index 0f0b748..b84e6b8 100644 --- a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift @@ -7,81 +7,183 @@ struct OnboardingFirstTaskContent: View { var onTaskAdded: () -> Void @StateObject private var viewModel = TaskViewModel() - @State private var selectedTask: TaskTemplate? - @State private var isCreatingTask = false + @State private var selectedTasks: Set = [] + @State private var isCreatingTasks = false @State private var showCustomTaskSheet = false + @State private var expandedCategory: String? = nil - private let taskTemplates: [TaskTemplate] = [ - TaskTemplate( - icon: "fanblades.fill", - title: "Change HVAC Filter", - category: "hvac", - frequency: "monthly", - color: Color.appPrimary + private let taskCategories: [OnboardingTaskCategory] = [ + OnboardingTaskCategory( + name: "HVAC & Climate", + icon: "thermometer.medium", + color: Color.appPrimary, + tasks: [ + TaskTemplate(icon: "fanblades.fill", title: "Change HVAC Filter", category: "hvac", frequency: "monthly", color: Color.appPrimary), + TaskTemplate(icon: "air.conditioner.horizontal.fill", title: "Schedule AC Tune-Up", category: "hvac", frequency: "yearly", color: Color.appPrimary), + TaskTemplate(icon: "flame.fill", title: "Inspect Furnace", category: "hvac", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange), + TaskTemplate(icon: "wind", title: "Clean Air Ducts", category: "hvac", frequency: "yearly", color: Color.appSecondary) + ] ), - TaskTemplate( - icon: "smoke.fill", - title: "Check Smoke Detectors", - category: "safety", - frequency: "semiannually", - color: Color.appError + OnboardingTaskCategory( + name: "Safety & Security", + icon: "shield.checkered", + color: Color.appError, + tasks: [ + TaskTemplate(icon: "smoke.fill", title: "Test Smoke Detectors", category: "safety", frequency: "monthly", color: Color.appError), + TaskTemplate(icon: "dot.radiowaves.left.and.right", title: "Check CO Detectors", category: "safety", frequency: "monthly", color: Color.appError), + TaskTemplate(icon: "flame.fill", title: "Inspect Fire Extinguisher", category: "safety", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange), + TaskTemplate(icon: "lock.fill", title: "Test Garage Door Safety", category: "safety", frequency: "monthly", color: Color.appSecondary) + ] ), - TaskTemplate( - icon: "leaf.fill", - title: "Lawn Care", - category: "landscaping", - frequency: "weekly", - color: Color(hex: "#4CAF50") ?? .green - ), - TaskTemplate( + OnboardingTaskCategory( + name: "Plumbing", icon: "drop.fill", - title: "Check for Leaks", - category: "plumbing", - frequency: "monthly", - color: Color.appSecondary + color: Color.appSecondary, + tasks: [ + TaskTemplate(icon: "drop.fill", title: "Check for Leaks", category: "plumbing", frequency: "monthly", color: Color.appSecondary), + TaskTemplate(icon: "bolt.horizontal.fill", title: "Flush Water Heater", category: "plumbing", frequency: "yearly", color: Color(hex: "#FF9500") ?? .orange), + TaskTemplate(icon: "wrench.and.screwdriver.fill", title: "Clean Faucet Aerators", category: "plumbing", frequency: "quarterly", color: Color.appPrimary), + TaskTemplate(icon: "arrow.down.circle.fill", title: "Snake Drains", category: "plumbing", frequency: "quarterly", color: Color.appTextSecondary) + ] + ), + OnboardingTaskCategory( + name: "Outdoor & Lawn", + icon: "leaf.fill", + color: Color(hex: "#34C759") ?? .green, + tasks: [ + TaskTemplate(icon: "leaf.fill", title: "Lawn Care", category: "landscaping", frequency: "weekly", color: Color(hex: "#34C759") ?? .green), + TaskTemplate(icon: "cloud.rain.fill", title: "Clean Gutters", category: "exterior", frequency: "semiannually", color: Color.appSecondary), + TaskTemplate(icon: "sun.max.fill", title: "Check Sprinkler System", category: "landscaping", frequency: "monthly", color: Color(hex: "#FF9500") ?? .orange), + TaskTemplate(icon: "scissors", title: "Trim Trees & Shrubs", category: "landscaping", frequency: "quarterly", color: Color(hex: "#34C759") ?? .green) + ] + ), + OnboardingTaskCategory( + name: "Appliances", + icon: "refrigerator.fill", + color: Color.appAccent, + tasks: [ + TaskTemplate(icon: "refrigerator.fill", title: "Clean Refrigerator Coils", category: "appliances", frequency: "semiannually", color: Color.appAccent), + TaskTemplate(icon: "washer.fill", title: "Clean Washing Machine", category: "appliances", frequency: "monthly", color: Color.appSecondary), + TaskTemplate(icon: "dishwasher.fill", title: "Clean Dishwasher Filter", category: "appliances", frequency: "monthly", color: Color.appPrimary), + TaskTemplate(icon: "oven.fill", title: "Deep Clean Oven", category: "appliances", frequency: "quarterly", color: Color(hex: "#FF6B35") ?? .orange) + ] + ), + OnboardingTaskCategory( + name: "General Home", + icon: "house.fill", + color: Color(hex: "#AF52DE") ?? .purple, + tasks: [ + TaskTemplate(icon: "paintbrush.fill", title: "Touch Up Paint", category: "interior", frequency: "yearly", color: Color(hex: "#AF52DE") ?? .purple), + TaskTemplate(icon: "lightbulb.fill", title: "Replace Light Bulbs", category: "electrical", frequency: "monthly", color: Color.appAccent), + TaskTemplate(icon: "door.left.hand.closed", title: "Lubricate Door Hinges", category: "interior", frequency: "yearly", color: Color.appTextSecondary), + TaskTemplate(icon: "window.vertical.closed", title: "Clean Window Tracks", category: "interior", frequency: "semiannually", color: Color.appPrimary) + ] ) ] + private var allTasks: [TaskTemplate] { + taskCategories.flatMap { $0.tasks } + } + + private var selectedCount: Int { + selectedTasks.count + } + var body: some View { VStack(spacing: 0) { ScrollView { VStack(spacing: AppSpacing.xl) { - // Header - VStack(spacing: AppSpacing.sm) { + // Header with celebration + VStack(spacing: AppSpacing.md) { ZStack { + // Celebration circles Circle() - .fill(Color.appPrimary.opacity(0.1)) - .frame(width: 80, height: 80) + .fill( + RadialGradient( + colors: [Color.appPrimary.opacity(0.2), Color.clear], + center: .center, + startRadius: 30, + endRadius: 80 + ) + ) + .frame(width: 140, height: 140) + .offset(x: -15, y: -15) - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 40)) - .foregroundStyle(Color.appPrimary.gradient) + Circle() + .fill( + RadialGradient( + colors: [Color.appAccent.opacity(0.2), Color.clear], + center: .center, + startRadius: 30, + endRadius: 80 + ) + ) + .frame(width: 140, height: 140) + .offset(x: 15, y: 15) + + // Party icon + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.appPrimary, Color.appSecondary], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 80, height: 80) + + Image(systemName: "party.popper.fill") + .font(.system(size: 36)) + .foregroundColor(.white) + } + .shadow(color: Color.appPrimary.opacity(0.4), radius: 15, y: 8) } - Text("Your home is ready!") - .font(.title2) + Text("You're all set up!") + .font(.title) .fontWeight(.bold) .foregroundColor(Color.appTextPrimary) - Text("What's the first thing you want to track?") + Text("Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!") .font(.subheadline) .foregroundColor(Color.appTextSecondary) .multilineTextAlignment(.center) + .lineSpacing(4) } .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 + // Selection counter chip + if selectedCount > 0 { + HStack(spacing: AppSpacing.sm) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color.appPrimary) + + Text("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(Color.appPrimary) + } + .padding(.horizontal, AppSpacing.lg) + .padding(.vertical, AppSpacing.sm) + .background(Color.appPrimary.opacity(0.1)) + .cornerRadius(AppRadius.xl) + .animation(.spring(response: 0.3), value: selectedCount) + } + + // Task categories + VStack(spacing: AppSpacing.md) { + ForEach(taskCategories) { category in + TaskCategorySection( + category: category, + selectedTasks: $selectedTasks, + isExpanded: expandedCategory == category.name, + onToggleExpand: { + withAnimation(.spring(response: 0.3)) { + if expandedCategory == category.name { + expandedCategory = nil } else { - selectedTask = template + expandedCategory = category.name } } } @@ -90,81 +192,124 @@ struct OnboardingFirstTaskContent: View { } .padding(.horizontal, AppSpacing.lg) - // Custom task option - Button(action: { - showCustomTaskSheet = true - }) { + // Quick add all popular + Button(action: selectPopularTasks) { HStack(spacing: AppSpacing.sm) { - Image(systemName: "plus.circle.fill") - .font(.title2) - .foregroundColor(Color.appPrimary) + Image(systemName: "sparkles") + .font(.headline) - Text("Add Custom Task") + Text("Add Most Popular") .font(.headline) .fontWeight(.medium) - .foregroundColor(Color.appPrimary) } + .foregroundStyle( + LinearGradient( + colors: [Color.appPrimary, Color.appAccent], + startPoint: .leading, + endPoint: .trailing + ) + ) .frame(maxWidth: .infinity) .frame(height: 56) - .background(Color.appPrimary.opacity(0.1)) - .cornerRadius(AppRadius.md) + .background( + LinearGradient( + colors: [Color.appPrimary.opacity(0.1), Color.appAccent.opacity(0.1)], + startPoint: .leading, + endPoint: .trailing + ) + ) + .cornerRadius(AppRadius.lg) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.lg) + .stroke( + LinearGradient( + colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)], + startPoint: .leading, + endPoint: .trailing + ), + lineWidth: 1.5 + ) + ) } .padding(.horizontal, AppSpacing.lg) } - .padding(.bottom, 120) // Space for button + .padding(.bottom, 140) // 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") + Button(action: addSelectedTasks) { + HStack(spacing: AppSpacing.sm) { + if isCreatingTasks { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Text(selectedCount > 0 ? "Add \(selectedCount) Task\(selectedCount == 1 ? "" : "s") & Continue" : "Skip for Now") + .font(.headline) + .fontWeight(.bold) + + Image(systemName: "arrow.right") .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) + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + selectedCount > 0 + ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing)) + : AnyShapeStyle(Color.appTextSecondary.opacity(0.5)) + ) + .cornerRadius(AppRadius.lg) + .shadow(color: selectedCount > 0 ? Color.appPrimary.opacity(0.4) : .clear, radius: 15, y: 8) } + .disabled(isCreatingTasks) + .animation(.easeInOut(duration: 0.2), value: selectedCount) } .padding(.horizontal, AppSpacing.xl) - .padding(.bottom, AppSpacing.xl) + .padding(.bottom, AppSpacing.xxxl) .background( LinearGradient( colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary], startPoint: .top, - endPoint: .bottom + endPoint: .center ) - .frame(height: 40) - .offset(y: -40) + .frame(height: 60) + .offset(y: -60) , alignment: .top ) } .background(Color.appBackgroundPrimary) - .sheet(isPresented: $showCustomTaskSheet) { - // TODO: Show custom task form - Text("Custom Task Form") + .onAppear { + // Expand first category by default + expandedCategory = taskCategories.first?.name } } - private func addSelectedTask() { - guard let template = selectedTask else { return } + private func selectPopularTasks() { + // Select top 6 most common tasks + let popularTaskTitles = [ + "Change HVAC Filter", + "Test Smoke Detectors", + "Check for Leaks", + "Clean Gutters", + "Clean Refrigerator Coils", + "Clean Washing Machine" + ] + + withAnimation(.spring(response: 0.3)) { + for task in allTasks where popularTaskTitles.contains(task.title) { + selectedTasks.insert(task.id) + } + } + } + + private func addSelectedTasks() { + // If no tasks selected, just skip + if selectedTasks.isEmpty { + onTaskAdded() + return + } // Get the first residence from cache (just created during onboarding) guard let residences = DataCache.shared.residences.value as? [ResidenceResponse], @@ -174,53 +319,225 @@ struct OnboardingFirstTaskContent: View { return } - isCreatingTask = true + isCreatingTasks = 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 - }() + let selectedTemplates = allTasks.filter { selectedTasks.contains($0.id) } + var completedCount = 0 + let totalCount = selectedTemplates.count // 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)") + print("🏠 ONBOARDING: Creating \(totalCount) tasks for residence \(residence.id)") - 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 - ) + for template in selectedTemplates { + // Look up category ID from DataCache + let categoryId: Int32? = { + guard let categories = DataCache.shared.taskCategories.value as? [ComposeApp.TaskCategory] else { return nil } + let categoryName = template.category.lowercased() + return categories.first { $0.name.lowercased() == categoryName }?.id + }() - viewModel.createTask(request: request) { success in - print("🏠 ONBOARDING: Task creation result: \(success ? "SUCCESS" : "FAILED")") - self.isCreatingTask = false - self.onTaskAdded() + // 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 + }() + + print("🏠 ONBOARDING: Creating task '\(template.title)' - categoryId=\(String(describing: categoryId)), frequencyId=\(String(describing: frequencyId))") + + 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 + completedCount += 1 + print("🏠 ONBOARDING: Task '\(template.title)' creation: \(success ? "SUCCESS" : "FAILED") (\(completedCount)/\(totalCount))") + + if completedCount == totalCount { + self.isCreatingTasks = false + self.onTaskAdded() + } + } } } } +// MARK: - Onboarding Task Category Model + +struct OnboardingTaskCategory: Identifiable { + let id = UUID() + let name: String + let icon: String + let color: Color + let tasks: [TaskTemplate] +} + +// MARK: - Task Category Section + +struct TaskCategorySection: View { + let category: OnboardingTaskCategory + @Binding var selectedTasks: Set + let isExpanded: Bool + var onToggleExpand: () -> Void + + private var selectedInCategory: Int { + category.tasks.filter { selectedTasks.contains($0.id) }.count + } + + var body: some View { + VStack(spacing: 0) { + // Category header + Button(action: onToggleExpand) { + HStack(spacing: AppSpacing.md) { + // Category icon + ZStack { + Circle() + .fill( + LinearGradient( + colors: [category.color, category.color.opacity(0.7)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 44, height: 44) + + Image(systemName: category.icon) + .font(.title3) + .foregroundColor(.white) + } + + // Category name + Text(category.name) + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(Color.appTextPrimary) + + Spacer() + + // Selection badge + if selectedInCategory > 0 { + Text("\(selectedInCategory)") + .font(.caption) + .fontWeight(.bold) + .foregroundColor(.white) + .frame(width: 24, height: 24) + .background(category.color) + .clipShape(Circle()) + } + + // Chevron + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(Color.appTextSecondary) + } + .padding(AppSpacing.md) + .background(Color.appBackgroundSecondary) + .cornerRadius(isExpanded ? AppRadius.lg : AppRadius.lg, corners: isExpanded ? [.topLeft, .topRight] : .allCorners) + } + .buttonStyle(.plain) + + // Expanded tasks + if isExpanded { + VStack(spacing: 0) { + ForEach(category.tasks) { task in + TaskTemplateRow( + template: task, + isSelected: selectedTasks.contains(task.id), + onTap: { + withAnimation(.spring(response: 0.2)) { + if selectedTasks.contains(task.id) { + selectedTasks.remove(task.id) + } else { + selectedTasks.insert(task.id) + } + } + } + ) + + if task.id != category.tasks.last?.id { + Divider() + .padding(.leading, 60) + } + } + } + .background(Color.appBackgroundSecondary.opacity(0.5)) + .cornerRadius(AppRadius.lg, corners: [.bottomLeft, .bottomRight]) + } + } + .shadow(color: Color.black.opacity(0.05), radius: 8, y: 4) + } +} + +// MARK: - Task Template Row + +struct TaskTemplateRow: View { + let template: TaskTemplate + let isSelected: Bool + var onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: AppSpacing.md) { + // Checkbox + ZStack { + Circle() + .stroke(isSelected ? template.color : Color.appTextSecondary.opacity(0.3), lineWidth: 2) + .frame(width: 28, height: 28) + + if isSelected { + Circle() + .fill(template.color) + .frame(width: 28, height: 28) + + Image(systemName: "checkmark") + .font(.caption) + .fontWeight(.bold) + .foregroundColor(.white) + } + } + + // Task info + VStack(alignment: .leading, spacing: 2) { + Text(template.title) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(Color.appTextPrimary) + + Text(template.frequency.capitalized) + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } + + Spacer() + + // Task icon + Image(systemName: template.icon) + .font(.title3) + .foregroundColor(template.color.opacity(0.6)) + } + .padding(.horizontal, AppSpacing.md) + .padding(.vertical, AppSpacing.sm) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + // MARK: - Task Template Model struct TaskTemplate: Identifiable { @@ -232,54 +549,6 @@ struct TaskTemplate: Identifiable { 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 { diff --git a/iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift b/iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift index bd9590b..8959a17 100644 --- a/iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift @@ -1,56 +1,98 @@ import SwiftUI -/// Screen 2: Name your home - Content only (no navigation bar) +/// Screen 3: Name your home - Content only (no navigation bar) struct OnboardingNameResidenceContent: View { @Binding var residenceName: String var onContinue: () -> Void @FocusState private var isTextFieldFocused: Bool + @State private var showSuggestions = false private var isValid: Bool { !residenceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + private let nameSuggestions = [ + "Casa de [Your Name]", + "The Cozy Corner", + "Home Sweet Home", + "The Nest", + "Château Us" + ] + var body: some View { VStack(spacing: 0) { Spacer() // Content VStack(spacing: AppSpacing.xl) { - // Icon + // Animated house icon ZStack { + // Colorful background circles Circle() - .fill(Color.appPrimary.opacity(0.1)) - .frame(width: 100, height: 100) + .fill( + RadialGradient( + colors: [Color.appAccent.opacity(0.2), Color.clear], + center: .center, + startRadius: 30, + endRadius: 80 + ) + ) + .frame(width: 160, height: 160) + .offset(x: -20, y: -20) + Circle() + .fill( + RadialGradient( + colors: [Color.appPrimary.opacity(0.2), Color.clear], + center: .center, + startRadius: 30, + endRadius: 80 + ) + ) + .frame(width: 160, height: 160) + .offset(x: 20, y: 20) + + // Main icon Image("icon") .resizable() .scaledToFit() - .frame(width: 50, height: 50) + .frame(width: 100, height: 100) + .shadow(color: Color.appPrimary.opacity(0.3), radius: 15, y: 8) } - // Title - VStack(spacing: AppSpacing.sm) { - Text("What should we call your place?") - .font(.title2) + // Title with playful wording + VStack(spacing: AppSpacing.md) { + Text("Let's give your place a name!") + .font(.title) .fontWeight(.bold) .foregroundColor(Color.appTextPrimary) .multilineTextAlignment(.center) - Text("You can always change this later") + Text("Don't worry, nothing's written in stone here.\nYou can always change it later in the app.") .font(.subheadline) .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) } - // Text field - VStack(alignment: .leading, spacing: AppSpacing.xs) { + // Text field with gradient border when focused + VStack(alignment: .leading, spacing: AppSpacing.sm) { HStack(spacing: AppSpacing.sm) { - Image(systemName: "pencil") - .foregroundColor(Color.appTextSecondary) - .frame(width: 20) + Image(systemName: "house.fill") + .font(.title3) + .foregroundStyle( + LinearGradient( + colors: [Color.appPrimary, Color.appAccent], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 24) - TextField("My Home", text: $residenceName) + TextField("The Smith Residence", text: $residenceName) .font(.body) + .fontWeight(.medium) .textInputAutocapitalization(.words) .focused($isTextFieldFocused) .submitLabel(.continue) @@ -59,16 +101,60 @@ struct OnboardingNameResidenceContent: View { onContinue() } } + + if !residenceName.isEmpty { + Button(action: { residenceName = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(Color.appTextSecondary.opacity(0.5)) + } + } } - .padding(AppSpacing.md) + .padding(AppSpacing.lg) .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.md) + .cornerRadius(AppRadius.lg) .overlay( - RoundedRectangle(cornerRadius: AppRadius.md) - .stroke(isTextFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5) + RoundedRectangle(cornerRadius: AppRadius.lg) + .stroke( + isTextFieldFocused + ? LinearGradient(colors: [Color.appPrimary, Color.appAccent], startPoint: .leading, endPoint: .trailing) + : LinearGradient(colors: [Color.appTextSecondary.opacity(0.3), Color.appTextSecondary.opacity(0.3)], startPoint: .leading, endPoint: .trailing), + lineWidth: 2 + ) ) - .shadow(color: isTextFieldFocused ? Color.appPrimary.opacity(0.1) : .clear, radius: 8) + .shadow(color: isTextFieldFocused ? Color.appPrimary.opacity(0.15) : .clear, radius: 12, y: 4) .animation(.easeInOut(duration: 0.2), value: isTextFieldFocused) + + // Name suggestions + if residenceName.isEmpty { + VStack(alignment: .leading, spacing: AppSpacing.xs) { + Text("Need inspiration?") + .font(.caption) + .fontWeight(.medium) + .foregroundColor(Color.appTextSecondary) + .padding(.top, AppSpacing.xs) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: AppSpacing.sm) { + ForEach(nameSuggestions, id: \.self) { suggestion in + Button(action: { + withAnimation(.spring(response: 0.3)) { + residenceName = suggestion + } + }) { + Text(suggestion) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(Color.appPrimary) + .padding(.horizontal, AppSpacing.md) + .padding(.vertical, AppSpacing.sm) + .background(Color.appPrimary.opacity(0.1)) + .cornerRadius(AppRadius.md) + } + } + } + } + } + } } .padding(.horizontal, AppSpacing.xl) } @@ -77,23 +163,29 @@ struct OnboardingNameResidenceContent: View { // 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) + HStack(spacing: AppSpacing.sm) { + Text("That's Perfect!") + .font(.headline) + .fontWeight(.bold) + + Image(systemName: "arrow.right") + .font(.headline) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + isValid + ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing)) + : AnyShapeStyle(Color.appTextSecondary.opacity(0.5)) + ) + .cornerRadius(AppRadius.lg) + .shadow(color: isValid ? Color.appPrimary.opacity(0.4) : .clear, radius: 15, y: 8) } .disabled(!isValid) .padding(.horizontal, AppSpacing.xl) .padding(.bottom, AppSpacing.xxxl) + .animation(.easeInOut(duration: 0.2), value: isValid) } .background(Color.appBackgroundPrimary) .onAppear { @@ -123,7 +215,7 @@ struct OnboardingNameResidenceView: View { Spacer() - OnboardingProgressIndicator(currentStep: 1, totalSteps: 5) + OnboardingProgressIndicator(currentStep: 2, totalSteps: 5) Spacer() diff --git a/iosApp/iosApp/Onboarding/OnboardingState.swift b/iosApp/iosApp/Onboarding/OnboardingState.swift index e508dfa..22c8e76 100644 --- a/iosApp/iosApp/Onboarding/OnboardingState.swift +++ b/iosApp/iosApp/Onboarding/OnboardingState.swift @@ -46,17 +46,18 @@ class OnboardingState: ObservableObject { } /// Move to the next step in the flow + /// New order: Welcome → Features → Name Residence → Create Account → Verify → Tasks → Upsell func nextStep() { switch currentStep { case .welcome: if userIntent == .joinExisting { currentStep = .createAccount } else { - currentStep = .nameResidence + currentStep = .valueProps // Features first to wow the user } - case .nameResidence: - currentStep = .valueProps case .valueProps: + currentStep = .nameResidence // Then name the house + case .nameResidence: currentStep = .createAccount case .createAccount: currentStep = .verifyEmail diff --git a/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift b/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift index 5468d4c..46a9561 100644 --- a/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift @@ -6,99 +6,155 @@ struct OnboardingSubscriptionContent: View { var onSubscribe: () -> Void @State private var isLoading = false + @State private var selectedPlan: PricingPlan = .yearly + @State private var animateBadge = false private let benefits: [SubscriptionBenefit] = [ SubscriptionBenefit( icon: "building.2.fill", title: "Unlimited Properties", - description: "Manage multiple homes, rentals, or vacation properties" + description: "Track every home you own—vacation houses, rentals, you name it", + gradient: [Color.appPrimary, Color.appSecondary] ), SubscriptionBenefit( - icon: "checklist", - title: "Unlimited Tasks", - description: "Track as many maintenance items as you need" + icon: "bell.badge.fill", + title: "Smart Reminders", + description: "Never miss a maintenance deadline. Ever.", + gradient: [Color.appAccent, Color(hex: "#FF9500") ?? .orange] ), SubscriptionBenefit( - icon: "doc.fill", - title: "Warranty Storage", - description: "Keep all your warranty documents in one place" + icon: "doc.badge.plus", + title: "Document Vault", + description: "All your warranties, receipts, and manuals in one searchable place", + gradient: [Color(hex: "#34C759") ?? .green, Color(hex: "#30D158") ?? .green] ), SubscriptionBenefit( - icon: "person.2.fill", - title: "Household Sharing", - description: "Invite family members to collaborate on tasks" + icon: "person.3.fill", + title: "Family Sharing", + description: "Get everyone on the same page—literally", + gradient: [Color(hex: "#AF52DE") ?? .purple, Color(hex: "#BF5AF2") ?? .purple] + ), + SubscriptionBenefit( + icon: "chart.line.uptrend.xyaxis", + title: "Spending Insights", + description: "See where your money goes and plan smarter", + gradient: [Color(hex: "#FF3B30") ?? .red, Color(hex: "#FF6961") ?? .red] ) ] var body: some View { ScrollView { VStack(spacing: AppSpacing.xl) { - // Header + // Header with animated crown VStack(spacing: AppSpacing.md) { + ZStack { + // Glow effect + Circle() + .fill( + RadialGradient( + colors: [Color.appAccent.opacity(0.3), Color.clear], + center: .center, + startRadius: 30, + endRadius: 100 + ) + ) + .frame(width: 180, height: 180) + .scaleEffect(animateBadge ? 1.1 : 1.0) + .animation(.easeInOut(duration: 2).repeatForever(autoreverses: true), value: animateBadge) + + // Crown icon + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 100, height: 100) + + Image(systemName: "crown.fill") + .font(.system(size: 44)) + .foregroundColor(.white) + } + .shadow(color: Color.appAccent.opacity(0.5), radius: 20, y: 10) + } + // Pro badge HStack(spacing: AppSpacing.xs) { - Image(systemName: "star.fill") + Image(systemName: "sparkles") .foregroundColor(Color.appAccent) - Text("PRO") + Text("CASERA PRO") .font(.headline) - .fontWeight(.bold) + .fontWeight(.black) + .foregroundColor(Color.appAccent) + Image(systemName: "sparkles") .foregroundColor(Color.appAccent) } - .padding(.horizontal, AppSpacing.md) - .padding(.vertical, AppSpacing.xs) - .background(Color.appAccent.opacity(0.15)) + .padding(.horizontal, AppSpacing.lg) + .padding(.vertical, AppSpacing.sm) + .background( + LinearGradient( + colors: [Color.appAccent.opacity(0.15), Color(hex: "#FF9500")?.opacity(0.15) ?? Color.orange.opacity(0.15)], + startPoint: .leading, + endPoint: .trailing + ) + ) .clipShape(Capsule()) - Text("Unlock the full power of Casera") - .font(.title) + Text("Take your home management\nto the next level") + .font(.title2) .fontWeight(.bold) .foregroundColor(Color.appTextPrimary) .multilineTextAlignment(.center) + .lineSpacing(4) - Text("Get more done with Pro features") - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) + // Social proof + HStack(spacing: AppSpacing.xs) { + ForEach(0..<5, id: \.self) { _ in + Image(systemName: "star.fill") + .font(.caption) + .foregroundColor(Color.appAccent) + } + Text("4.9") + .font(.subheadline) + .fontWeight(.bold) + .foregroundColor(Color.appTextPrimary) + Text("• 10K+ homeowners") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + } } - .padding(.top, AppSpacing.xxl) + .padding(.top, AppSpacing.lg) - // Benefits list - VStack(spacing: AppSpacing.md) { + // Benefits list with gradient icons + VStack(spacing: AppSpacing.sm) { ForEach(benefits) { benefit in SubscriptionBenefitRow(benefit: benefit) } } .padding(.horizontal, AppSpacing.lg) - // Pricing card + // Pricing plans 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) - } + Text("Choose your plan") + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(Color.appTextPrimary) - Spacer() + // Yearly plan (best value) + PricingPlanCard( + plan: .yearly, + isSelected: selectedPlan == .yearly, + onSelect: { selectedPlan = .yearly } + ) - 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) + // Monthly plan + PricingPlanCard( + plan: .monthly, + isSelected: selectedPlan == .monthly, + onSelect: { selectedPlan = .monthly } ) } .padding(.horizontal, AppSpacing.lg) @@ -106,42 +162,65 @@ struct OnboardingSubscriptionContent: View { // CTA buttons VStack(spacing: AppSpacing.md) { Button(action: startFreeTrial) { - HStack { + HStack(spacing: AppSpacing.sm) { if isLoading { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Text("Start 7-Day Free Trial") + .font(.headline) + .fontWeight(.bold) + + Image(systemName: "arrow.right") + .font(.headline) } - 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 + colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange], + startPoint: .leading, + endPoint: .trailing ) ) - .cornerRadius(AppRadius.md) - .shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5) + .cornerRadius(AppRadius.lg) + .shadow(color: Color.appAccent.opacity(0.4), radius: 15, y: 8) } .disabled(isLoading) + // Continue without + Button(action: { + onSubscribe() + }) { + Text("Continue with Free") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(Color.appTextSecondary) + } + // 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) + VStack(spacing: AppSpacing.xs) { + Text("7-day free trial, then \(selectedPlan == .yearly ? "$29.99/year" : "$4.99/month")") + .font(.caption) + .foregroundColor(Color.appTextSecondary) + + Text("Cancel anytime in Settings • No commitment") + .font(.caption) + .foregroundColor(Color.appTextSecondary.opacity(0.7)) + } + .multilineTextAlignment(.center) + .padding(.top, AppSpacing.xs) } .padding(.horizontal, AppSpacing.xl) .padding(.bottom, AppSpacing.xxxl) } } .background(Color.appBackgroundPrimary) + .onAppear { + animateBadge = true + } } private func startFreeTrial() { @@ -169,6 +248,135 @@ struct OnboardingSubscriptionContent: View { } } +// MARK: - Pricing Plan Enum + +enum PricingPlan { + case monthly + case yearly + + var title: String { + switch self { + case .monthly: return "Monthly" + case .yearly: return "Yearly" + } + } + + var price: String { + switch self { + case .monthly: return "$4.99" + case .yearly: return "$29.99" + } + } + + var period: String { + switch self { + case .monthly: return "/month" + case .yearly: return "/year" + } + } + + var monthlyEquivalent: String? { + switch self { + case .monthly: return nil + case .yearly: return "Just $2.50/month" + } + } + + var savings: String? { + switch self { + case .monthly: return nil + case .yearly: return "Save 50%" + } + } +} + +// MARK: - Pricing Plan Card + +struct PricingPlanCard: View { + let plan: PricingPlan + let isSelected: Bool + var onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack { + // Selection indicator + ZStack { + Circle() + .stroke(isSelected ? Color.appAccent : Color.appTextSecondary.opacity(0.3), lineWidth: 2) + .frame(width: 24, height: 24) + + if isSelected { + Circle() + .fill(Color.appAccent) + .frame(width: 14, height: 14) + } + } + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: AppSpacing.sm) { + Text(plan.title) + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(Color.appTextPrimary) + + if let savings = plan.savings { + Text(savings) + .font(.caption) + .fontWeight(.bold) + .foregroundColor(.white) + .padding(.horizontal, AppSpacing.sm) + .padding(.vertical, 2) + .background( + LinearGradient( + colors: [Color(hex: "#34C759") ?? .green, Color(hex: "#30D158") ?? .green], + startPoint: .leading, + endPoint: .trailing + ) + ) + .clipShape(Capsule()) + } + } + + if let monthlyEquivalent = plan.monthlyEquivalent { + Text(monthlyEquivalent) + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 0) { + Text(plan.price) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(isSelected ? Color.appAccent : Color.appTextPrimary) + + Text(plan.period) + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } + } + .padding(AppSpacing.lg) + .background(Color.appBackgroundSecondary) + .cornerRadius(AppRadius.lg) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.lg) + .stroke( + isSelected + ? LinearGradient(colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange], startPoint: .leading, endPoint: .trailing) + : LinearGradient(colors: [Color.clear, Color.clear], startPoint: .leading, endPoint: .trailing), + lineWidth: 2 + ) + ) + .shadow(color: isSelected ? Color.appAccent.opacity(0.15) : .clear, radius: 10, y: 4) + } + .buttonStyle(.plain) + .animation(.easeInOut(duration: 0.2), value: isSelected) + } +} + // MARK: - Legacy wrapper with navigation bar (for backwards compatibility) struct OnboardingSubscriptionView: View { @@ -187,6 +395,7 @@ struct SubscriptionBenefit: Identifiable { let icon: String let title: String let description: String + let gradient: [Color] } // MARK: - Subscription Benefit Row @@ -196,36 +405,45 @@ struct SubscriptionBenefitRow: View { var body: some View { HStack(spacing: AppSpacing.md) { + // Gradient icon ZStack { Circle() - .fill(Color.appPrimary.opacity(0.1)) + .fill( + LinearGradient( + colors: benefit.gradient, + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) .frame(width: 44, height: 44) Image(systemName: benefit.icon) .font(.title3) - .foregroundColor(Color.appPrimary) + .foregroundColor(.white) } + .shadow(color: benefit.gradient[0].opacity(0.3), radius: 8, y: 4) - VStack(alignment: .leading, spacing: AppSpacing.xxs) { + VStack(alignment: .leading, spacing: 2) { Text(benefit.title) - .font(.headline) + .font(.subheadline) + .fontWeight(.semibold) .foregroundColor(Color.appTextPrimary) Text(benefit.description) - .font(.subheadline) + .font(.caption) .foregroundColor(Color.appTextSecondary) .lineLimit(2) } Spacer() - Image(systemName: "checkmark.circle.fill") - .font(.title2) - .foregroundColor(Color.appPrimary) + Image(systemName: "checkmark") + .font(.caption) + .fontWeight(.bold) + .foregroundColor(benefit.gradient[0]) } - .padding(AppSpacing.md) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.lg) + .padding(.horizontal, AppSpacing.md) + .padding(.vertical, AppSpacing.sm) } } diff --git a/iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift b/iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift index 73ece9f..9dd4fdc 100644 --- a/iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift @@ -1,76 +1,97 @@ import SwiftUI -/// Screen 3: Swipeable value propositions carousel - Content only (no navigation bar) +/// Screen 2: Features showcase - Wow the user with what Casera can do struct OnboardingValuePropsContent: View { var onContinue: () -> Void @State private var currentPage = 0 + @State private var animateFeatures = false - 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 + private let features: [FeatureHighlight] = [ + FeatureHighlight( + icon: "clock.badge.checkmark.fill", + title: "Never Forget Again", + subtitle: "Your memory just got an upgrade", + description: "Smart reminders keep you on top of furnace filters, gutter cleaning, and everything in between. No more \"when did I last...?\" moments.", + gradient: [Color.appPrimary, Color.appSecondary], + statNumber: "47%", + statLabel: "of homeowners forget routine maintenance" ), - 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 + FeatureHighlight( + icon: "dollarsign.circle.fill", + title: "Save Real Money", + subtitle: "Prevention beats repair every time", + description: "Catch small issues before they become expensive disasters. A $30 filter change beats a $5,000 HVAC replacement any day.", + gradient: [Color(hex: "#34C759") ?? .green, Color(hex: "#30D158") ?? .green], + statNumber: "$3,000+", + statLabel: "average savings per year" ), - ValueProp( + FeatureHighlight( + icon: "doc.text.fill", + title: "Warranties at Your Fingertips", + subtitle: "No more digging through drawers", + description: "Snap a photo of your receipts and warranties. When something breaks, you'll know exactly what's covered—instantly.", + gradient: [Color.appAccent, Color(hex: "#FF9500") ?? .orange], + statNumber: "60%", + statLabel: "of warranties go unused" + ), + FeatureHighlight( 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 + title: "The Whole Family's In", + subtitle: "Teamwork makes the dream work", + description: "Share your home with family members. Everyone sees what needs doing, and nobody can claim they \"didn't know.\"", + gradient: [Color(hex: "#AF52DE") ?? .purple, Color(hex: "#BF5AF2") ?? .purple], + statNumber: "2x", + statLabel: "more tasks completed with sharing" ) ] var body: some View { VStack(spacing: 0) { - Spacer() - - // Carousel + // Feature cards in a tab view TabView(selection: $currentPage) { - ForEach(Array(valueProps.enumerated()), id: \.offset) { index, prop in - ValuePropCard(prop: prop) + ForEach(Array(features.enumerated()), id: \.offset) { index, feature in + FeatureCard(feature: feature, isActive: currentPage == index) .tag(index) + .padding(.horizontal, AppSpacing.lg) } } .tabViewStyle(.page(indexDisplayMode: .never)) - .frame(height: 400) + .frame(maxHeight: .infinity) - // Page indicator - HStack(spacing: AppSpacing.xs) { - ForEach(0..