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:
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user