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