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)
/// 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()

View File

@@ -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<UUID> = []
@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<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
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 {

View File

@@ -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()

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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..<valueProps.count, id: \.self) { index in
Circle()
.fill(index == currentPage ? Color.appPrimary : Color.appTextSecondary.opacity(0.3))
.frame(width: 8, height: 8)
.animation(.easeInOut(duration: 0.2), value: currentPage)
// Custom page indicator
HStack(spacing: AppSpacing.sm) {
ForEach(0..<features.count, id: \.self) { index in
Capsule()
.fill(currentPage == index ? Color.appPrimary : Color.appTextSecondary.opacity(0.3))
.frame(width: currentPage == index ? 24 : 8, height: 8)
.animation(.spring(response: 0.3), value: currentPage)
}
}
.padding(.top, AppSpacing.lg)
Spacer()
.padding(.bottom, AppSpacing.xl)
// Continue button
Button(action: onContinue) {
Text("Get Started")
.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
)
HStack(spacing: AppSpacing.sm) {
Text("I'm Ready!")
.font(.headline)
.fontWeight(.bold)
Image(systemName: "arrow.right")
.font(.headline)
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.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(.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 icon: String
let title: String
let subtitle: 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 {
let prop: ValueProp
struct FeatureCard: View {
let feature: FeatureHighlight
let isActive: Bool
@State private var appeared = false
var body: some View {
VStack(spacing: AppSpacing.xl) {
// Icon
Spacer()
// Large icon with gradient background
ZStack {
// Outer glow
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)
.shadow(color: feature.gradient[0].opacity(0.5), radius: 20, y: 10)
Image(systemName: prop.icon)
Image(systemName: feature.icon)
.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
VStack(spacing: AppSpacing.sm) {
Text(prop.title)
.font(.title2)
// Text content
VStack(spacing: AppSpacing.md) {
Text(feature.title)
.font(.title)
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary)
.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)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
.lineSpacing(4)
.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()
OnboardingProgressIndicator(currentStep: 2, totalSteps: 5)
OnboardingProgressIndicator(currentStep: 1, totalSteps: 5)
Spacer()