Redesign iOS onboarding UI with improved visuals and engagement

- 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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-02 11:49:55 -06:00
parent 0652908c20
commit d4b5da71b7
6 changed files with 1050 additions and 360 deletions

View File

@@ -101,11 +101,12 @@ struct OnboardingCoordinator: View {
} }
/// Current step index for progress indicator (0-based) /// Current step index for progress indicator (0-based)
/// Flow: Welcome Features Name Residence Create Account Verify Tasks Upsell
private var currentProgressStep: Int { private var currentProgressStep: Int {
switch onboardingState.currentStep { switch onboardingState.currentStep {
case .welcome: return 0 case .welcome: return 0
case .nameResidence: return 1 case .valueProps: return 1
case .valueProps: return 2 case .nameResidence: return 2
case .createAccount: return 3 case .createAccount: return 3
case .verifyEmail: return 4 case .verifyEmail: return 4
case .joinResidence: return 4 case .joinResidence: return 4
@@ -145,16 +146,17 @@ struct OnboardingCoordinator: View {
} }
private func handleBack() { private func handleBack() {
// Flow: Welcome Features Name Residence Create Account Verify Tasks Upsell
switch onboardingState.currentStep { switch onboardingState.currentStep {
case .nameResidence:
goBack(to: .welcome)
case .valueProps: case .valueProps:
goBack(to: .nameResidence) goBack(to: .welcome)
case .nameResidence:
goBack(to: .valueProps)
case .createAccount: case .createAccount:
if onboardingState.userIntent == .joinExisting { if onboardingState.userIntent == .joinExisting {
goBack(to: .welcome) goBack(to: .welcome)
} else { } else {
goBack(to: .valueProps) goBack(to: .nameResidence)
} }
case .verifyEmail: case .verifyEmail:
AuthenticationManager.shared.logout() AuthenticationManager.shared.logout()

View File

@@ -7,81 +7,183 @@ struct OnboardingFirstTaskContent: View {
var onTaskAdded: () -> Void var onTaskAdded: () -> Void
@StateObject private var viewModel = TaskViewModel() @StateObject private var viewModel = TaskViewModel()
@State private var selectedTask: TaskTemplate? @State private var selectedTasks: Set<UUID> = []
@State private var isCreatingTask = false @State private var isCreatingTasks = false
@State private var showCustomTaskSheet = false @State private var showCustomTaskSheet = false
@State private var expandedCategory: String? = nil
private let taskTemplates: [TaskTemplate] = [ private let taskCategories: [OnboardingTaskCategory] = [
TaskTemplate( OnboardingTaskCategory(
icon: "fanblades.fill", name: "HVAC & Climate",
title: "Change HVAC Filter", icon: "thermometer.medium",
category: "hvac", color: Color.appPrimary,
frequency: "monthly", tasks: [
color: Color.appPrimary 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( OnboardingTaskCategory(
icon: "smoke.fill", name: "Safety & Security",
title: "Check Smoke Detectors", icon: "shield.checkered",
category: "safety", color: Color.appError,
frequency: "semiannually", tasks: [
color: Color.appError 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( OnboardingTaskCategory(
icon: "leaf.fill", name: "Plumbing",
title: "Lawn Care",
category: "landscaping",
frequency: "weekly",
color: Color(hex: "#4CAF50") ?? .green
),
TaskTemplate(
icon: "drop.fill", icon: "drop.fill",
title: "Check for Leaks", color: Color.appSecondary,
category: "plumbing", tasks: [
frequency: "monthly", TaskTemplate(icon: "drop.fill", title: "Check for Leaks", category: "plumbing", frequency: "monthly", color: Color.appSecondary),
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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
ScrollView { ScrollView {
VStack(spacing: AppSpacing.xl) { VStack(spacing: AppSpacing.xl) {
// Header // Header with celebration
VStack(spacing: AppSpacing.sm) { VStack(spacing: AppSpacing.md) {
ZStack { ZStack {
// Celebration circles
Circle() Circle()
.fill(Color.appPrimary.opacity(0.1)) .fill(
.frame(width: 80, height: 80) 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") Circle()
.font(.system(size: 40)) .fill(
.foregroundStyle(Color.appPrimary.gradient) 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!") Text("You're all set up!")
.font(.title2) .font(.title)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .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) .font(.subheadline)
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineSpacing(4)
} }
.padding(.top, AppSpacing.lg) .padding(.top, AppSpacing.lg)
// Task templates grid // Selection counter chip
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: AppSpacing.md) { if selectedCount > 0 {
ForEach(taskTemplates) { template in HStack(spacing: AppSpacing.sm) {
TaskTemplateCard( Image(systemName: "checkmark.circle.fill")
template: template, .foregroundColor(Color.appPrimary)
isSelected: selectedTask?.id == template.id,
onTap: { Text("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected")
withAnimation(.easeInOut(duration: 0.2)) { .font(.subheadline)
if selectedTask?.id == template.id { .fontWeight(.medium)
selectedTask = nil .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 { } else {
selectedTask = template expandedCategory = category.name
} }
} }
} }
@@ -90,81 +192,124 @@ struct OnboardingFirstTaskContent: View {
} }
.padding(.horizontal, AppSpacing.lg) .padding(.horizontal, AppSpacing.lg)
// Custom task option // Quick add all popular
Button(action: { Button(action: selectPopularTasks) {
showCustomTaskSheet = true
}) {
HStack(spacing: AppSpacing.sm) { HStack(spacing: AppSpacing.sm) {
Image(systemName: "plus.circle.fill") Image(systemName: "sparkles")
.font(.title2) .font(.headline)
.foregroundColor(Color.appPrimary)
Text("Add Custom Task") Text("Add Most Popular")
.font(.headline) .font(.headline)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(Color.appPrimary)
} }
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary, Color.appAccent],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 56) .frame(height: 56)
.background(Color.appPrimary.opacity(0.1)) .background(
.cornerRadius(AppRadius.md) 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(.horizontal, AppSpacing.lg)
} }
.padding(.bottom, 120) // Space for button .padding(.bottom, 140) // Space for button
} }
// Bottom action area // Bottom action area
VStack(spacing: AppSpacing.md) { VStack(spacing: AppSpacing.md) {
if selectedTask != nil { Button(action: addSelectedTasks) {
Button(action: addSelectedTask) { HStack(spacing: AppSpacing.sm) {
HStack { if isCreatingTasks {
if isCreatingTask { ProgressView()
ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .white))
.progressViewStyle(CircularProgressViewStyle(tint: .white)) } else {
} Text(selectedCount > 0 ? "Add \(selectedCount) Task\(selectedCount == 1 ? "" : "s") & Continue" : "Skip for Now")
Text(isCreatingTask ? "Adding Task..." : "Add Task & Continue") .font(.headline)
.fontWeight(.bold)
Image(systemName: "arrow.right")
.font(.headline) .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(.horizontal, AppSpacing.xl)
.padding(.bottom, AppSpacing.xl) .padding(.bottom, AppSpacing.xxxl)
.background( .background(
LinearGradient( LinearGradient(
colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary], colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary],
startPoint: .top, startPoint: .top,
endPoint: .bottom endPoint: .center
) )
.frame(height: 40) .frame(height: 60)
.offset(y: -40) .offset(y: -60)
, alignment: .top , alignment: .top
) )
} }
.background(Color.appBackgroundPrimary) .background(Color.appBackgroundPrimary)
.sheet(isPresented: $showCustomTaskSheet) { .onAppear {
// TODO: Show custom task form // Expand first category by default
Text("Custom Task Form") expandedCategory = taskCategories.first?.name
} }
} }
private func addSelectedTask() { private func selectPopularTasks() {
guard let template = selectedTask else { return } // 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) // Get the first residence from cache (just created during onboarding)
guard let residences = DataCache.shared.residences.value as? [ResidenceResponse], guard let residences = DataCache.shared.residences.value as? [ResidenceResponse],
@@ -174,53 +319,225 @@ struct OnboardingFirstTaskContent: View {
return return
} }
isCreatingTask = true isCreatingTasks = true
// Look up category ID from DataCache let selectedTemplates = allTasks.filter { selectedTasks.contains($0.id) }
let categoryId: Int32? = { var completedCount = 0
guard let categories = DataCache.shared.taskCategories.value as? [TaskCategory] else { return nil } let totalCount = selectedTemplates.count
// 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 // Format today's date as YYYY-MM-DD for the API
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd" dateFormatter.dateFormat = "yyyy-MM-dd"
let todayString = dateFormatter.string(from: Date()) let todayString = dateFormatter.string(from: Date())
print("🏠 ONBOARDING: Creating task '\(template.title)' for residence \(residence.id)") print("🏠 ONBOARDING: Creating \(totalCount) tasks for residence \(residence.id)")
print("🏠 ONBOARDING: categoryId=\(String(describing: categoryId)), frequencyId=\(String(describing: frequencyId)), dueDate=\(todayString)")
let request = TaskCreateRequest( for template in selectedTemplates {
residenceId: residence.id, // Look up category ID from DataCache
title: template.title, let categoryId: Int32? = {
description: nil, guard let categories = DataCache.shared.taskCategories.value as? [ComposeApp.TaskCategory] else { return nil }
categoryId: categoryId.map { KotlinInt(int: $0) }, let categoryName = template.category.lowercased()
priorityId: nil, return categories.first { $0.name.lowercased() == categoryName }?.id
statusId: nil, }()
frequencyId: frequencyId.map { KotlinInt(int: $0) },
assignedToId: nil,
dueDate: todayString,
estimatedCost: nil,
contractorId: nil
)
viewModel.createTask(request: request) { success in // Look up frequency ID from DataCache
print("🏠 ONBOARDING: Task creation result: \(success ? "SUCCESS" : "FAILED")") let frequencyId: Int32? = {
self.isCreatingTask = false guard let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency] else { return nil }
self.onTaskAdded() 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<UUID>
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 // MARK: - Task Template Model
struct TaskTemplate: Identifiable { struct TaskTemplate: Identifiable {
@@ -232,54 +549,6 @@ struct TaskTemplate: Identifiable {
let color: Color 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) // MARK: - Legacy wrapper with navigation bar (for backwards compatibility)
struct OnboardingFirstTaskView: View { struct OnboardingFirstTaskView: View {

View File

@@ -1,56 +1,98 @@
import SwiftUI 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 { struct OnboardingNameResidenceContent: View {
@Binding var residenceName: String @Binding var residenceName: String
var onContinue: () -> Void var onContinue: () -> Void
@FocusState private var isTextFieldFocused: Bool @FocusState private var isTextFieldFocused: Bool
@State private var showSuggestions = false
private var isValid: Bool { private var isValid: Bool {
!residenceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty !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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
// Content // Content
VStack(spacing: AppSpacing.xl) { VStack(spacing: AppSpacing.xl) {
// Icon // Animated house icon
ZStack { ZStack {
// Colorful background circles
Circle() Circle()
.fill(Color.appPrimary.opacity(0.1)) .fill(
.frame(width: 100, height: 100) 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") Image("icon")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 50, height: 50) .frame(width: 100, height: 100)
.shadow(color: Color.appPrimary.opacity(0.3), radius: 15, y: 8)
} }
// Title // Title with playful wording
VStack(spacing: AppSpacing.sm) { VStack(spacing: AppSpacing.md) {
Text("What should we call your place?") Text("Let's give your place a name!")
.font(.title2) .font(.title)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center) .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) .font(.subheadline)
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
} }
// Text field // Text field with gradient border when focused
VStack(alignment: .leading, spacing: AppSpacing.xs) { VStack(alignment: .leading, spacing: AppSpacing.sm) {
HStack(spacing: AppSpacing.sm) { HStack(spacing: AppSpacing.sm) {
Image(systemName: "pencil") Image(systemName: "house.fill")
.foregroundColor(Color.appTextSecondary) .font(.title3)
.frame(width: 20) .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) .font(.body)
.fontWeight(.medium)
.textInputAutocapitalization(.words) .textInputAutocapitalization(.words)
.focused($isTextFieldFocused) .focused($isTextFieldFocused)
.submitLabel(.continue) .submitLabel(.continue)
@@ -59,16 +101,60 @@ struct OnboardingNameResidenceContent: View {
onContinue() 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) .background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.md) .cornerRadius(AppRadius.lg)
.overlay( .overlay(
RoundedRectangle(cornerRadius: AppRadius.md) RoundedRectangle(cornerRadius: AppRadius.lg)
.stroke(isTextFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5) .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) .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) .padding(.horizontal, AppSpacing.xl)
} }
@@ -77,23 +163,29 @@ struct OnboardingNameResidenceContent: View {
// Continue button // Continue button
Button(action: onContinue) { Button(action: onContinue) {
Text("Continue") HStack(spacing: AppSpacing.sm) {
.font(.headline) Text("That's Perfect!")
.fontWeight(.semibold) .font(.headline)
.frame(maxWidth: .infinity) .fontWeight(.bold)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary) Image(systemName: "arrow.right")
.background( .font(.headline)
isValid }
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing)) .frame(maxWidth: .infinity)
: AnyShapeStyle(Color.appTextSecondary) .frame(height: 56)
) .foregroundColor(Color.appTextOnPrimary)
.cornerRadius(AppRadius.md) .background(
.shadow(color: isValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5) 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) .disabled(!isValid)
.padding(.horizontal, AppSpacing.xl) .padding(.horizontal, AppSpacing.xl)
.padding(.bottom, AppSpacing.xxxl) .padding(.bottom, AppSpacing.xxxl)
.animation(.easeInOut(duration: 0.2), value: isValid)
} }
.background(Color.appBackgroundPrimary) .background(Color.appBackgroundPrimary)
.onAppear { .onAppear {
@@ -123,7 +215,7 @@ struct OnboardingNameResidenceView: View {
Spacer() Spacer()
OnboardingProgressIndicator(currentStep: 1, totalSteps: 5) OnboardingProgressIndicator(currentStep: 2, totalSteps: 5)
Spacer() Spacer()

View File

@@ -46,17 +46,18 @@ class OnboardingState: ObservableObject {
} }
/// Move to the next step in the flow /// Move to the next step in the flow
/// New order: Welcome Features Name Residence Create Account Verify Tasks Upsell
func nextStep() { func nextStep() {
switch currentStep { switch currentStep {
case .welcome: case .welcome:
if userIntent == .joinExisting { if userIntent == .joinExisting {
currentStep = .createAccount currentStep = .createAccount
} else { } else {
currentStep = .nameResidence currentStep = .valueProps // Features first to wow the user
} }
case .nameResidence:
currentStep = .valueProps
case .valueProps: case .valueProps:
currentStep = .nameResidence // Then name the house
case .nameResidence:
currentStep = .createAccount currentStep = .createAccount
case .createAccount: case .createAccount:
currentStep = .verifyEmail currentStep = .verifyEmail

View File

@@ -6,99 +6,155 @@ struct OnboardingSubscriptionContent: View {
var onSubscribe: () -> Void var onSubscribe: () -> Void
@State private var isLoading = false @State private var isLoading = false
@State private var selectedPlan: PricingPlan = .yearly
@State private var animateBadge = false
private let benefits: [SubscriptionBenefit] = [ private let benefits: [SubscriptionBenefit] = [
SubscriptionBenefit( SubscriptionBenefit(
icon: "building.2.fill", icon: "building.2.fill",
title: "Unlimited Properties", 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( SubscriptionBenefit(
icon: "checklist", icon: "bell.badge.fill",
title: "Unlimited Tasks", title: "Smart Reminders",
description: "Track as many maintenance items as you need" description: "Never miss a maintenance deadline. Ever.",
gradient: [Color.appAccent, Color(hex: "#FF9500") ?? .orange]
), ),
SubscriptionBenefit( SubscriptionBenefit(
icon: "doc.fill", icon: "doc.badge.plus",
title: "Warranty Storage", title: "Document Vault",
description: "Keep all your warranty documents in one place" description: "All your warranties, receipts, and manuals in one searchable place",
gradient: [Color(hex: "#34C759") ?? .green, Color(hex: "#30D158") ?? .green]
), ),
SubscriptionBenefit( SubscriptionBenefit(
icon: "person.2.fill", icon: "person.3.fill",
title: "Household Sharing", title: "Family Sharing",
description: "Invite family members to collaborate on tasks" 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 { var body: some View {
ScrollView { ScrollView {
VStack(spacing: AppSpacing.xl) { VStack(spacing: AppSpacing.xl) {
// Header // Header with animated crown
VStack(spacing: AppSpacing.md) { 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 // Pro badge
HStack(spacing: AppSpacing.xs) { HStack(spacing: AppSpacing.xs) {
Image(systemName: "star.fill") Image(systemName: "sparkles")
.foregroundColor(Color.appAccent) .foregroundColor(Color.appAccent)
Text("PRO") Text("CASERA PRO")
.font(.headline) .font(.headline)
.fontWeight(.bold) .fontWeight(.black)
.foregroundColor(Color.appAccent)
Image(systemName: "sparkles")
.foregroundColor(Color.appAccent) .foregroundColor(Color.appAccent)
} }
.padding(.horizontal, AppSpacing.md) .padding(.horizontal, AppSpacing.lg)
.padding(.vertical, AppSpacing.xs) .padding(.vertical, AppSpacing.sm)
.background(Color.appAccent.opacity(0.15)) .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()) .clipShape(Capsule())
Text("Unlock the full power of Casera") Text("Take your home management\nto the next level")
.font(.title) .font(.title2)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineSpacing(4)
Text("Get more done with Pro features") // Social proof
.font(.subheadline) HStack(spacing: AppSpacing.xs) {
.foregroundColor(Color.appTextSecondary) 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 // Benefits list with gradient icons
VStack(spacing: AppSpacing.md) { VStack(spacing: AppSpacing.sm) {
ForEach(benefits) { benefit in ForEach(benefits) { benefit in
SubscriptionBenefitRow(benefit: benefit) SubscriptionBenefitRow(benefit: benefit)
} }
} }
.padding(.horizontal, AppSpacing.lg) .padding(.horizontal, AppSpacing.lg)
// Pricing card // Pricing plans
VStack(spacing: AppSpacing.md) { VStack(spacing: AppSpacing.md) {
HStack { Text("Choose your plan")
VStack(alignment: .leading, spacing: AppSpacing.xxs) { .font(.headline)
Text("Monthly") .fontWeight(.semibold)
.font(.headline) .foregroundColor(Color.appTextPrimary)
.foregroundColor(Color.appTextPrimary)
Text("Cancel anytime")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
Spacer() // Yearly plan (best value)
PricingPlanCard(
plan: .yearly,
isSelected: selectedPlan == .yearly,
onSelect: { selectedPlan = .yearly }
)
VStack(alignment: .trailing, spacing: AppSpacing.xxs) { // Monthly plan
Text("$4.99") PricingPlanCard(
.font(.title2) plan: .monthly,
.fontWeight(.bold) isSelected: selectedPlan == .monthly,
.foregroundColor(Color.appPrimary) onSelect: { selectedPlan = .monthly }
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) .padding(.horizontal, AppSpacing.lg)
@@ -106,42 +162,65 @@ struct OnboardingSubscriptionContent: View {
// CTA buttons // CTA buttons
VStack(spacing: AppSpacing.md) { VStack(spacing: AppSpacing.md) {
Button(action: startFreeTrial) { Button(action: startFreeTrial) {
HStack { HStack(spacing: AppSpacing.sm) {
if isLoading { if isLoading {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white)) .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(maxWidth: .infinity)
.frame(height: 56) .frame(height: 56)
.foregroundColor(Color.appTextOnPrimary) .foregroundColor(Color.appTextOnPrimary)
.background( .background(
LinearGradient( LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange],
startPoint: .topLeading, startPoint: .leading,
endPoint: .bottomTrailing endPoint: .trailing
) )
) )
.cornerRadius(AppRadius.md) .cornerRadius(AppRadius.lg)
.shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5) .shadow(color: Color.appAccent.opacity(0.4), radius: 15, y: 8)
} }
.disabled(isLoading) .disabled(isLoading)
// Continue without
Button(action: {
onSubscribe()
}) {
Text("Continue with Free")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(Color.appTextSecondary)
}
// Legal text // Legal text
Text("7-day free trial, then $4.99/month. Cancel anytime in Settings.") VStack(spacing: AppSpacing.xs) {
.font(.caption) Text("7-day free trial, then \(selectedPlan == .yearly ? "$29.99/year" : "$4.99/month")")
.foregroundColor(Color.appTextSecondary) .font(.caption)
.multilineTextAlignment(.center) .foregroundColor(Color.appTextSecondary)
.padding(.top, AppSpacing.xs)
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(.horizontal, AppSpacing.xl)
.padding(.bottom, AppSpacing.xxxl) .padding(.bottom, AppSpacing.xxxl)
} }
} }
.background(Color.appBackgroundPrimary) .background(Color.appBackgroundPrimary)
.onAppear {
animateBadge = true
}
} }
private func startFreeTrial() { 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) // MARK: - Legacy wrapper with navigation bar (for backwards compatibility)
struct OnboardingSubscriptionView: View { struct OnboardingSubscriptionView: View {
@@ -187,6 +395,7 @@ struct SubscriptionBenefit: Identifiable {
let icon: String let icon: String
let title: String let title: String
let description: String let description: String
let gradient: [Color]
} }
// MARK: - Subscription Benefit Row // MARK: - Subscription Benefit Row
@@ -196,36 +405,45 @@ struct SubscriptionBenefitRow: View {
var body: some View { var body: some View {
HStack(spacing: AppSpacing.md) { HStack(spacing: AppSpacing.md) {
// Gradient icon
ZStack { ZStack {
Circle() Circle()
.fill(Color.appPrimary.opacity(0.1)) .fill(
LinearGradient(
colors: benefit.gradient,
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
Image(systemName: benefit.icon) Image(systemName: benefit.icon)
.font(.title3) .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) Text(benefit.title)
.font(.headline) .font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Text(benefit.description) Text(benefit.description)
.font(.subheadline) .font(.caption)
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.lineLimit(2) .lineLimit(2)
} }
Spacer() Spacer()
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark")
.font(.title2) .font(.caption)
.foregroundColor(Color.appPrimary) .fontWeight(.bold)
.foregroundColor(benefit.gradient[0])
} }
.padding(AppSpacing.md) .padding(.horizontal, AppSpacing.md)
.background(Color.appBackgroundSecondary) .padding(.vertical, AppSpacing.sm)
.cornerRadius(AppRadius.lg)
} }
} }

View File

@@ -1,76 +1,97 @@
import SwiftUI 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 { struct OnboardingValuePropsContent: View {
var onContinue: () -> Void var onContinue: () -> Void
@State private var currentPage = 0 @State private var currentPage = 0
@State private var animateFeatures = false
private let valueProps: [ValueProp] = [ private let features: [FeatureHighlight] = [
ValueProp( FeatureHighlight(
icon: "checklist", icon: "clock.badge.checkmark.fill",
title: "Track Maintenance Tasks", title: "Never Forget Again",
description: "Never forget when the furnace filter is due. Set one-time or recurring tasks and get reminders.", subtitle: "Your memory just got an upgrade",
color: Color.appPrimary 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( FeatureHighlight(
icon: "person.text.rectangle", icon: "dollarsign.circle.fill",
title: "Save Contractor Info", title: "Save Real Money",
description: "Keep your trusted pros in one place. No more digging for business cards or phone numbers.", subtitle: "Prevention beats repair every time",
color: Color.appSecondary 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", icon: "person.2.fill",
title: "Share with Family", title: "The Whole Family's In",
description: "Get the whole household on the same page. Everyone can see what's due and mark tasks complete.", subtitle: "Teamwork makes the dream work",
color: Color.appAccent 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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() // Feature cards in a tab view
// Carousel
TabView(selection: $currentPage) { TabView(selection: $currentPage) {
ForEach(Array(valueProps.enumerated()), id: \.offset) { index, prop in ForEach(Array(features.enumerated()), id: \.offset) { index, feature in
ValuePropCard(prop: prop) FeatureCard(feature: feature, isActive: currentPage == index)
.tag(index) .tag(index)
.padding(.horizontal, AppSpacing.lg)
} }
} }
.tabViewStyle(.page(indexDisplayMode: .never)) .tabViewStyle(.page(indexDisplayMode: .never))
.frame(height: 400) .frame(maxHeight: .infinity)
// Page indicator // Custom page indicator
HStack(spacing: AppSpacing.xs) { HStack(spacing: AppSpacing.sm) {
ForEach(0..<valueProps.count, id: \.self) { index in ForEach(0..<features.count, id: \.self) { index in
Circle() Capsule()
.fill(index == currentPage ? Color.appPrimary : Color.appTextSecondary.opacity(0.3)) .fill(currentPage == index ? Color.appPrimary : Color.appTextSecondary.opacity(0.3))
.frame(width: 8, height: 8) .frame(width: currentPage == index ? 24 : 8, height: 8)
.animation(.easeInOut(duration: 0.2), value: currentPage) .animation(.spring(response: 0.3), value: currentPage)
} }
} }
.padding(.top, AppSpacing.lg) .padding(.bottom, AppSpacing.xl)
Spacer()
// Continue button // Continue button
Button(action: onContinue) { Button(action: onContinue) {
Text("Get Started") HStack(spacing: AppSpacing.sm) {
.font(.headline) Text("I'm Ready!")
.fontWeight(.semibold) .font(.headline)
.frame(maxWidth: .infinity) .fontWeight(.bold)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary) Image(systemName: "arrow.right")
.background( .font(.headline)
LinearGradient( }
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], .frame(maxWidth: .infinity)
startPoint: .topLeading, .frame(height: 56)
endPoint: .bottomTrailing .foregroundColor(Color.appTextOnPrimary)
) .background(
LinearGradient(
colors: [Color.appPrimary, Color.appSecondary],
startPoint: .leading,
endPoint: .trailing
) )
.cornerRadius(AppRadius.md) )
.shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5) .cornerRadius(AppRadius.lg)
.shadow(color: Color.appPrimary.opacity(0.4), radius: 15, y: 8)
} }
.padding(.horizontal, AppSpacing.xl) .padding(.horizontal, AppSpacing.xl)
.padding(.bottom, AppSpacing.xxxl) .padding(.bottom, AppSpacing.xxxl)
@@ -79,52 +100,139 @@ struct OnboardingValuePropsContent: View {
} }
} }
// MARK: - Value Prop Model // MARK: - Feature Highlight Model
struct ValueProp: Identifiable { struct FeatureHighlight: Identifiable {
let id = UUID() let id = UUID()
let icon: String let icon: String
let title: String let title: String
let subtitle: String
let description: String let description: String
let color: Color let gradient: [Color]
let statNumber: String
let statLabel: String
} }
// MARK: - Value Prop Card // MARK: - Feature Card
struct ValuePropCard: View { struct FeatureCard: View {
let prop: ValueProp let feature: FeatureHighlight
let isActive: Bool
@State private var appeared = false
var body: some View { var body: some View {
VStack(spacing: AppSpacing.xl) { VStack(spacing: AppSpacing.xl) {
// Icon Spacer()
// Large icon with gradient background
ZStack { ZStack {
// Outer glow
Circle() Circle()
.fill(prop.color.opacity(0.1)) .fill(
RadialGradient(
colors: [feature.gradient[0].opacity(0.3), Color.clear],
center: .center,
startRadius: 40,
endRadius: 100
)
)
.frame(width: 200, height: 200)
.scaleEffect(appeared ? 1 : 0.8)
.opacity(appeared ? 1 : 0)
// Icon circle
Circle()
.fill(
LinearGradient(
colors: feature.gradient,
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 120, height: 120) .frame(width: 120, height: 120)
.shadow(color: feature.gradient[0].opacity(0.5), radius: 20, y: 10)
Image(systemName: prop.icon) Image(systemName: feature.icon)
.font(.system(size: 50)) .font(.system(size: 50))
.foregroundStyle(prop.color.gradient) .foregroundColor(.white)
} }
.scaleEffect(appeared ? 1 : 0.5)
.animation(.spring(response: 0.5, dampingFraction: 0.7), value: appeared)
// Text // Text content
VStack(spacing: AppSpacing.sm) { VStack(spacing: AppSpacing.md) {
Text(prop.title) Text(feature.title)
.font(.title2) .font(.title)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Text(prop.description) Text(feature.subtitle)
.font(.title3)
.fontWeight(.medium)
.foregroundStyle(
LinearGradient(
colors: feature.gradient,
startPoint: .leading,
endPoint: .trailing
)
)
.multilineTextAlignment(.center)
Text(feature.description)
.font(.body) .font(.body)
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineLimit(4) .lineSpacing(4)
.fixedSize(horizontal: false, vertical: true) .padding(.horizontal, AppSpacing.md)
}
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 20)
.animation(.easeOut(duration: 0.4).delay(0.2), value: appeared)
// Stat highlight
VStack(spacing: AppSpacing.xs) {
Text(feature.statNumber)
.font(.system(size: 36, weight: .bold, design: .rounded))
.foregroundStyle(
LinearGradient(
colors: feature.gradient,
startPoint: .leading,
endPoint: .trailing
)
)
Text(feature.statLabel)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
.padding(AppSpacing.lg)
.background(
RoundedRectangle(cornerRadius: AppRadius.lg)
.fill(Color.appBackgroundSecondary)
)
.opacity(appeared ? 1 : 0)
.animation(.easeOut(duration: 0.4).delay(0.4), value: appeared)
Spacer()
}
.onChange(of: isActive) { _, newValue in
if newValue {
appeared = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
appeared = true
}
}
}
.onAppear {
if isActive {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
appeared = true
}
} }
.padding(.horizontal, AppSpacing.xl)
} }
.padding(.horizontal, AppSpacing.lg)
} }
} }
@@ -147,7 +255,7 @@ struct OnboardingValuePropsView: View {
Spacer() Spacer()
OnboardingProgressIndicator(currentStep: 2, totalSteps: 5) OnboardingProgressIndicator(currentStep: 1, totalSteps: 5)
Spacer() Spacer()