Smart onboarding: home profile, tabbed tasks, free app
New onboarding step: "Tell us about your home" with chip-based pickers for systems (heating/cooling/water heater), features (pool, fireplace, garage, etc.), exterior (roof, siding), interior (flooring, landscaping). All optional, skippable. Tabbed task selection: "For You" tab shows personalized suggestions based on home profile, "Browse All" has existing category browser. Removed 5-task limit — users can add unlimited tasks. Removed subscription upsell from onboarding flow — app is free. Fixed picker capsule squishing bug with .fixedSize() modifier. Both iOS and Compose implementations updated.
This commit is contained in:
@@ -69,6 +69,15 @@ struct OnboardingCoordinator: View {
|
||||
|
||||
isCreatingResidence = true
|
||||
|
||||
// Collect home profile booleans — only send true values
|
||||
let hasPool = onboardingState.pendingHasPool ? KotlinBoolean(bool: true) : nil
|
||||
let hasSprinkler = onboardingState.pendingHasSprinklerSystem ? KotlinBoolean(bool: true) : nil
|
||||
let hasSeptic = onboardingState.pendingHasSeptic ? KotlinBoolean(bool: true) : nil
|
||||
let hasFireplace = onboardingState.pendingHasFireplace ? KotlinBoolean(bool: true) : nil
|
||||
let hasGarage = onboardingState.pendingHasGarage ? KotlinBoolean(bool: true) : nil
|
||||
let hasBasement = onboardingState.pendingHasBasement ? KotlinBoolean(bool: true) : nil
|
||||
let hasAttic = onboardingState.pendingHasAttic ? KotlinBoolean(bool: true) : nil
|
||||
|
||||
let request = ResidenceCreateRequest(
|
||||
name: onboardingState.pendingResidenceName,
|
||||
propertyTypeId: nil,
|
||||
@@ -86,7 +95,21 @@ struct OnboardingCoordinator: View {
|
||||
description: nil,
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: KotlinBoolean(bool: true)
|
||||
isPrimary: KotlinBoolean(bool: true),
|
||||
heatingType: onboardingState.pendingHeatingType,
|
||||
coolingType: onboardingState.pendingCoolingType,
|
||||
waterHeaterType: onboardingState.pendingWaterHeaterType,
|
||||
roofType: onboardingState.pendingRoofType,
|
||||
hasPool: hasPool,
|
||||
hasSprinklerSystem: hasSprinkler,
|
||||
hasSeptic: hasSeptic,
|
||||
hasFireplace: hasFireplace,
|
||||
hasGarage: hasGarage,
|
||||
hasBasement: hasBasement,
|
||||
hasAttic: hasAttic,
|
||||
exteriorType: onboardingState.pendingExteriorType,
|
||||
flooringPrimary: onboardingState.pendingFlooringPrimary,
|
||||
landscapingType: onboardingState.pendingLandscapingType
|
||||
)
|
||||
|
||||
residenceViewModel.createResidence(request: request) { (residence: ResidenceResponse?) in
|
||||
@@ -103,7 +126,7 @@ struct OnboardingCoordinator: View {
|
||||
}
|
||||
|
||||
/// Current step index for progress indicator (0-based)
|
||||
/// Flow: Welcome → Features → Name → Account → Verify → Location → Tasks → Upsell
|
||||
/// Flow: Welcome → Features → Name → Account → Verify → Location → Home Profile → Tasks → Upsell
|
||||
private var currentProgressStep: Int {
|
||||
switch onboardingState.currentStep {
|
||||
case .welcome: return 0
|
||||
@@ -113,6 +136,7 @@ struct OnboardingCoordinator: View {
|
||||
case .verifyEmail: return 4
|
||||
case .joinResidence: return 4
|
||||
case .residenceLocation: return 4
|
||||
case .homeProfile: return 4
|
||||
case .firstTask: return 4
|
||||
case .subscriptionUpsell: return 4
|
||||
}
|
||||
@@ -121,7 +145,7 @@ struct OnboardingCoordinator: View {
|
||||
/// Whether to show the back button
|
||||
private var showBackButton: Bool {
|
||||
switch onboardingState.currentStep {
|
||||
case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
|
||||
case .welcome, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
@@ -131,7 +155,7 @@ struct OnboardingCoordinator: View {
|
||||
/// Whether to show the skip button
|
||||
private var showSkipButton: Bool {
|
||||
switch onboardingState.currentStep {
|
||||
case .valueProps, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
|
||||
case .valueProps, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -141,7 +165,7 @@ struct OnboardingCoordinator: View {
|
||||
/// Whether to show the progress indicator
|
||||
private var showProgressIndicator: Bool {
|
||||
switch onboardingState.currentStep {
|
||||
case .welcome, .joinResidence, .residenceLocation, .firstTask, .subscriptionUpsell:
|
||||
case .welcome, .joinResidence, .residenceLocation, .homeProfile, .firstTask, .subscriptionUpsell:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
@@ -174,11 +198,15 @@ struct OnboardingCoordinator: View {
|
||||
case .valueProps:
|
||||
goForward()
|
||||
case .residenceLocation:
|
||||
// Skipping location — still need to create residence (without postal code)
|
||||
// Skipping location — go to home profile
|
||||
goForward(to: .homeProfile)
|
||||
case .homeProfile:
|
||||
// Skipping home profile — create residence without profile data, go to tasks
|
||||
createResidenceIfNeeded(thenNavigateTo: .firstTask)
|
||||
case .joinResidence, .firstTask:
|
||||
goForward()
|
||||
case .subscriptionUpsell:
|
||||
case .joinResidence:
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
case .firstTask, .subscriptionUpsell:
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
default:
|
||||
@@ -301,7 +329,8 @@ struct OnboardingCoordinator: View {
|
||||
case .joinResidence:
|
||||
OnboardingJoinResidenceContent(
|
||||
onJoined: {
|
||||
goForward()
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
@@ -309,9 +338,21 @@ struct OnboardingCoordinator: View {
|
||||
case .residenceLocation:
|
||||
OnboardingLocationContent(
|
||||
onLocationDetected: { zip in
|
||||
// Load regional templates in background while creating residence
|
||||
// Load regional templates in background
|
||||
onboardingState.loadRegionalTemplates(zip: zip)
|
||||
// Create residence with postal code, then go to first task
|
||||
// Go to home profile step (residence created after profile)
|
||||
goForward()
|
||||
},
|
||||
onSkip: {
|
||||
// Handled by handleSkip() above
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .homeProfile:
|
||||
OnboardingHomeProfileContent(
|
||||
onContinue: {
|
||||
// Create residence with all collected data, then go to tasks
|
||||
createResidenceIfNeeded(thenNavigateTo: .firstTask)
|
||||
},
|
||||
onSkip: {
|
||||
@@ -324,20 +365,21 @@ struct OnboardingCoordinator: View {
|
||||
OnboardingFirstTaskContent(
|
||||
residenceName: onboardingState.pendingResidenceName,
|
||||
onTaskAdded: {
|
||||
goForward()
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .subscriptionUpsell:
|
||||
OnboardingSubscriptionContent(
|
||||
onSubscribe: {
|
||||
// Handle subscription flow
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
}
|
||||
)
|
||||
.transition(navigationTransition)
|
||||
|
||||
case .subscriptionUpsell:
|
||||
// Subscription removed from onboarding — app is free
|
||||
// Immediately complete if we somehow land here
|
||||
EmptyView()
|
||||
.onAppear {
|
||||
onboardingState.completeOnboarding()
|
||||
onComplete()
|
||||
}
|
||||
.transition(navigationTransition)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: onboardingState.currentStep)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
/// Tab selection for task browsing
|
||||
enum OnboardingTaskTab: String, CaseIterable {
|
||||
case forYou = "For You"
|
||||
case browse = "Browse All"
|
||||
}
|
||||
|
||||
/// Screen 6: First task prompt with suggested templates - Content only (no navigation bar)
|
||||
struct OnboardingFirstTaskContent: View {
|
||||
var residenceName: String
|
||||
@@ -13,10 +19,12 @@ struct OnboardingFirstTaskContent: View {
|
||||
@State private var isCreatingTasks = false
|
||||
@State private var expandedCategories: Set<String> = []
|
||||
@State private var isAnimating = false
|
||||
@State private var selectedTab: OnboardingTaskTab = .forYou
|
||||
@State private var forYouTemplates: [OnboardingTaskTemplate] = []
|
||||
@State private var isLoadingSuggestions = false
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
/// Maximum tasks allowed for free tier (matches API TierLimits)
|
||||
private let maxTasksAllowed = 5
|
||||
// No task selection limit — users can add as many as they want
|
||||
|
||||
/// Category colors by name (used for both API and fallback templates)
|
||||
private static let categoryColors: [String: Color] = [
|
||||
@@ -173,7 +181,7 @@ struct OnboardingFirstTaskContent: View {
|
||||
}
|
||||
|
||||
private var isAtMaxSelection: Bool {
|
||||
selectedTasks.count >= maxTasksAllowed
|
||||
false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -304,88 +312,107 @@ struct OnboardingFirstTaskContent: View {
|
||||
Image(systemName: isAtMaxSelection ? "checkmark.seal.fill" : "checkmark.circle.fill")
|
||||
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
|
||||
|
||||
Text("\(selectedCount)/\(maxTasksAllowed) tasks selected")
|
||||
Text("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 10)
|
||||
.background((isAtMaxSelection ? Color.appAccent : Color.appPrimary).opacity(0.1))
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
.animation(.spring(response: 0.3), value: selectedCount)
|
||||
.accessibilityLabel("\(selectedCount) of \(maxTasksAllowed) tasks selected")
|
||||
.accessibilityLabel("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected")
|
||||
|
||||
// Task categories
|
||||
VStack(spacing: 12) {
|
||||
ForEach(taskCategories) { category in
|
||||
OrganicTaskCategorySection(
|
||||
category: category,
|
||||
selectedTasks: $selectedTasks,
|
||||
isExpanded: expandedCategories.contains(category.name),
|
||||
isAtMaxSelection: isAtMaxSelection,
|
||||
onToggleExpand: {
|
||||
let isExpanding = !expandedCategories.contains(category.name)
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
if expandedCategories.contains(category.name) {
|
||||
expandedCategories.remove(category.name)
|
||||
} else {
|
||||
expandedCategories.insert(category.name)
|
||||
// Tab bar
|
||||
OnboardingTaskTabBar(selectedTab: $selectedTab)
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
|
||||
// Tab content
|
||||
switch selectedTab {
|
||||
case .forYou:
|
||||
// For You tab — personalized suggestions
|
||||
ForYouTasksTab(
|
||||
forYouTemplates: forYouTemplates,
|
||||
isLoading: isLoadingSuggestions,
|
||||
selectedTasks: $selectedTasks,
|
||||
isAtMaxSelection: isAtMaxSelection,
|
||||
hasResidence: onboardingState.createdResidenceId != nil
|
||||
)
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
|
||||
case .browse:
|
||||
// Browse tab — existing category browser
|
||||
VStack(spacing: 12) {
|
||||
ForEach(taskCategories) { category in
|
||||
OrganicTaskCategorySection(
|
||||
category: category,
|
||||
selectedTasks: $selectedTasks,
|
||||
isExpanded: expandedCategories.contains(category.name),
|
||||
isAtMaxSelection: isAtMaxSelection,
|
||||
onToggleExpand: {
|
||||
let isExpanding = !expandedCategories.contains(category.name)
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
if expandedCategories.contains(category.name) {
|
||||
expandedCategories.remove(category.name)
|
||||
} else {
|
||||
expandedCategories.insert(category.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if isExpanding {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
withAnimation {
|
||||
proxy.scrollTo(category.name, anchor: .top)
|
||||
if isExpanding {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
withAnimation {
|
||||
proxy.scrollTo(category.name, anchor: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.id(category.name)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
|
||||
// Quick add all popular
|
||||
Button(action: selectPopularTasks) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
|
||||
Text("Add Most Popular")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appAccent],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.1), Color.appAccent.opacity(0.1)],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
),
|
||||
lineWidth: 1.5
|
||||
)
|
||||
)
|
||||
.id(category.name)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
|
||||
// Quick add all popular
|
||||
Button(action: selectPopularTasks) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
|
||||
Text("Add Most Popular")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appAccent],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.1), Color.appAccent.opacity(0.1)],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
),
|
||||
lineWidth: 1.5
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
.a11yButton("Add popular tasks")
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
.a11yButton("Add popular tasks")
|
||||
}
|
||||
.padding(.bottom, 140) // Space for button
|
||||
}
|
||||
@@ -448,6 +475,8 @@ struct OnboardingFirstTaskContent: View {
|
||||
if let first = taskCategories.first?.name {
|
||||
expandedCategories.insert(first)
|
||||
}
|
||||
// Build "For You" suggestions based on home profile
|
||||
buildForYouSuggestions()
|
||||
}
|
||||
.onDisappear {
|
||||
isAnimating = false
|
||||
@@ -457,11 +486,9 @@ struct OnboardingFirstTaskContent: View {
|
||||
private func selectPopularTasks() {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
if !onboardingState.regionalTemplates.isEmpty {
|
||||
// API templates: select the first N tasks (they're ordered by display_order)
|
||||
// API templates: select the first tasks (they're ordered by display_order)
|
||||
for task in allTasks {
|
||||
if selectedTasks.count < maxTasksAllowed {
|
||||
selectedTasks.insert(task.id)
|
||||
}
|
||||
selectedTasks.insert(task.id)
|
||||
}
|
||||
} else {
|
||||
// Fallback: select hardcoded popular tasks
|
||||
@@ -473,14 +500,164 @@ struct OnboardingFirstTaskContent: View {
|
||||
"Clean Refrigerator Coils"
|
||||
]
|
||||
for task in allTasks where popularTaskTitles.contains(task.title) {
|
||||
if selectedTasks.count < maxTasksAllowed {
|
||||
selectedTasks.insert(task.id)
|
||||
}
|
||||
selectedTasks.insert(task.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build personalized "For You" suggestions based on the home profile selections
|
||||
private func buildForYouSuggestions() {
|
||||
var suggestions: [ForYouSuggestion] = []
|
||||
|
||||
let state = onboardingState
|
||||
|
||||
// HVAC-related suggestions based on heating/cooling type
|
||||
if state.pendingHeatingType != nil || state.pendingCoolingType != nil {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "fanblades.fill", title: "Change HVAC Filter", category: "hvac", frequency: "monthly", color: .appPrimary),
|
||||
relevance: .great, reason: "Based on your HVAC system"
|
||||
))
|
||||
}
|
||||
if state.pendingHeatingType == "gas_furnace" || state.pendingHeatingType == "boiler" {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "flame.fill", title: "Inspect Furnace", category: "hvac", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange),
|
||||
relevance: .great, reason: "You have a gas system"
|
||||
))
|
||||
}
|
||||
if state.pendingCoolingType == "central_ac" || state.pendingCoolingType == "heat_pump" {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "air.conditioner.horizontal.fill", title: "Schedule AC Tune-Up", category: "hvac", frequency: "yearly", color: .appPrimary),
|
||||
relevance: .great, reason: "Central cooling needs annual service"
|
||||
))
|
||||
}
|
||||
|
||||
// Water heater
|
||||
if state.pendingWaterHeaterType != nil {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "bolt.horizontal.fill", title: "Flush Water Heater", category: "plumbing", frequency: "yearly", color: Color(hex: "#FF9500") ?? .orange),
|
||||
relevance: state.pendingWaterHeaterType?.contains("tank") == true ? .great : .good,
|
||||
reason: "Extends water heater life"
|
||||
))
|
||||
}
|
||||
|
||||
// Pool
|
||||
if state.pendingHasPool {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "figure.pool.swim", title: "Check Pool Chemistry", category: "exterior", frequency: "weekly", color: .appSecondary),
|
||||
relevance: .great, reason: "You have a pool"
|
||||
))
|
||||
}
|
||||
|
||||
// Sprinklers
|
||||
if state.pendingHasSprinklerSystem {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "sprinkler.and.droplets.fill", title: "Check Sprinkler System", category: "landscaping", frequency: "monthly", color: Color(hex: "#34C759") ?? .green),
|
||||
relevance: .great, reason: "You have sprinklers"
|
||||
))
|
||||
}
|
||||
|
||||
// Fireplace
|
||||
if state.pendingHasFireplace {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "fireplace.fill", title: "Inspect Chimney & Fireplace", category: "interior", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange),
|
||||
relevance: .great, reason: "You have a fireplace"
|
||||
))
|
||||
}
|
||||
|
||||
// Garage
|
||||
if state.pendingHasGarage {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "lock.fill", title: "Test Garage Door Safety", category: "safety", frequency: "monthly", color: .appSecondary),
|
||||
relevance: .good, reason: "You have a garage"
|
||||
))
|
||||
}
|
||||
|
||||
// Basement
|
||||
if state.pendingHasBasement {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "drop.fill", title: "Check Basement for Moisture", category: "interior", frequency: "monthly", color: .appSecondary),
|
||||
relevance: .good, reason: "You have a basement"
|
||||
))
|
||||
}
|
||||
|
||||
// Septic
|
||||
if state.pendingHasSeptic {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "drop.triangle.fill", title: "Schedule Septic Inspection", category: "plumbing", frequency: "yearly", color: .appPrimary),
|
||||
relevance: .great, reason: "You have a septic system"
|
||||
))
|
||||
}
|
||||
|
||||
// Attic
|
||||
if state.pendingHasAttic {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "arrow.up.square.fill", title: "Inspect Attic Insulation", category: "interior", frequency: "yearly", color: Color(hex: "#AF52DE") ?? .purple),
|
||||
relevance: .good, reason: "You have an attic"
|
||||
))
|
||||
}
|
||||
|
||||
// Roof-based
|
||||
if state.pendingRoofType != nil {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "cloud.rain.fill", title: "Clean Gutters", category: "exterior", frequency: "semiannually", color: .appSecondary),
|
||||
relevance: .great, reason: "Protects your roof"
|
||||
))
|
||||
}
|
||||
|
||||
// Always-recommended essentials (lower priority)
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "smoke.fill", title: "Test Smoke Detectors", category: "safety", frequency: "monthly", color: .appError),
|
||||
relevance: .good, reason: "Essential safety task"
|
||||
))
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "drop.fill", title: "Check for Leaks", category: "plumbing", frequency: "monthly", color: .appSecondary),
|
||||
relevance: .good, reason: "Prevents water damage"
|
||||
))
|
||||
|
||||
// Landscaping
|
||||
if state.pendingLandscapingType == "lawn" || state.pendingLandscapingType == "garden" || state.pendingLandscapingType == "mixed" {
|
||||
suggestions.append(ForYouSuggestion(
|
||||
template: OnboardingTaskTemplate(icon: "leaf.fill", title: "Lawn Care", category: "landscaping", frequency: "weekly", color: Color(hex: "#34C759") ?? .green),
|
||||
relevance: .good, reason: "Based on your landscaping"
|
||||
))
|
||||
}
|
||||
|
||||
// Sort: great first, then good; deduplicate by title
|
||||
var seen = Set<String>()
|
||||
let sorted = suggestions
|
||||
.sorted { $0.relevance.rawValue > $1.relevance.rawValue }
|
||||
.filter { seen.insert($0.template.title).inserted }
|
||||
|
||||
forYouTemplates = sorted.map { $0.template }
|
||||
|
||||
// If we have personalized suggestions, default to For You tab
|
||||
if !forYouTemplates.isEmpty && hasAnyHomeProfileData() {
|
||||
selectedTab = .forYou
|
||||
} else {
|
||||
selectedTab = .browse
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if user filled in any home profile data
|
||||
private func hasAnyHomeProfileData() -> Bool {
|
||||
let s = onboardingState
|
||||
return s.pendingHeatingType != nil ||
|
||||
s.pendingCoolingType != nil ||
|
||||
s.pendingWaterHeaterType != nil ||
|
||||
s.pendingRoofType != nil ||
|
||||
s.pendingHasPool ||
|
||||
s.pendingHasSprinklerSystem ||
|
||||
s.pendingHasSeptic ||
|
||||
s.pendingHasFireplace ||
|
||||
s.pendingHasGarage ||
|
||||
s.pendingHasBasement ||
|
||||
s.pendingHasAttic ||
|
||||
s.pendingExteriorType != nil ||
|
||||
s.pendingFlooringPrimary != nil ||
|
||||
s.pendingLandscapingType != nil
|
||||
}
|
||||
|
||||
private func addSelectedTasks() {
|
||||
// If no tasks selected, just skip
|
||||
if selectedTasks.isEmpty {
|
||||
@@ -497,9 +674,14 @@ struct OnboardingFirstTaskContent: View {
|
||||
|
||||
isCreatingTasks = true
|
||||
|
||||
let selectedTemplates = allTasks.filter { selectedTasks.contains($0.id) }
|
||||
// Collect from both browse and For You templates
|
||||
let allAvailable = allTasks + forYouTemplates
|
||||
let selectedTemplates = allAvailable.filter { selectedTasks.contains($0.id) }
|
||||
// Deduplicate by title (same task might exist in both tabs)
|
||||
var seenTitles = Set<String>()
|
||||
let uniqueTemplates = selectedTemplates.filter { seenTitles.insert($0.title).inserted }
|
||||
var completedCount = 0
|
||||
let totalCount = selectedTemplates.count
|
||||
let totalCount = uniqueTemplates.count
|
||||
|
||||
// Safety: if no templates matched (shouldn't happen), skip
|
||||
if totalCount == 0 {
|
||||
@@ -516,7 +698,7 @@ struct OnboardingFirstTaskContent: View {
|
||||
|
||||
print("🏠 ONBOARDING: Creating \(totalCount) tasks for residence \(residenceId)")
|
||||
|
||||
for template in selectedTemplates {
|
||||
for template in uniqueTemplates {
|
||||
// Look up category ID from DataManager
|
||||
let categoryId: Int32? = {
|
||||
return dataManager.taskCategories.first { $0.name.caseInsensitiveCompare(template.category) == .orderedSame }?.id
|
||||
@@ -760,6 +942,230 @@ struct OnboardingTaskTemplate: Identifiable {
|
||||
let color: Color
|
||||
}
|
||||
|
||||
// MARK: - For You Suggestion Model
|
||||
|
||||
enum SuggestionRelevance: Int {
|
||||
case good = 1
|
||||
case great = 2
|
||||
}
|
||||
|
||||
struct ForYouSuggestion {
|
||||
let template: OnboardingTaskTemplate
|
||||
let relevance: SuggestionRelevance
|
||||
let reason: String
|
||||
}
|
||||
|
||||
// MARK: - Tab Bar
|
||||
|
||||
private struct OnboardingTaskTabBar: View {
|
||||
@Binding var selectedTab: OnboardingTaskTab
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(OnboardingTaskTab.allCases, id: \.self) { tab in
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
selectedTab = tab
|
||||
}
|
||||
} label: {
|
||||
VStack(spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
if tab == .forYou {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
} else {
|
||||
Image(systemName: "square.grid.2x2.fill")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
}
|
||||
Text(tab.rawValue)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(selectedTab == tab ? Color.appPrimary : Color.appTextSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// Indicator
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(selectedTab == tab ? Color.appPrimary : Color.clear)
|
||||
.frame(height: 3)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
ZStack {
|
||||
Color.appBackgroundSecondary
|
||||
GrainTexture(opacity: 0.01)
|
||||
}
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - For You Tasks Tab
|
||||
|
||||
private struct ForYouTasksTab: View {
|
||||
let forYouTemplates: [OnboardingTaskTemplate]
|
||||
let isLoading: Bool
|
||||
@Binding var selectedTasks: Set<UUID>
|
||||
let isAtMaxSelection: Bool
|
||||
let hasResidence: Bool
|
||||
|
||||
var body: some View {
|
||||
if isLoading {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: Color.appPrimary))
|
||||
.scaleEffect(1.2)
|
||||
Text("Generating suggestions...")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
} else if forYouTemplates.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 64, height: 64)
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 28))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
|
||||
Text("No personalized suggestions yet")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text("Try the Browse tab to explore tasks by category,\nor add home details for better suggestions.")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 30)
|
||||
.padding(.horizontal, 16)
|
||||
.background(
|
||||
ZStack {
|
||||
Color.appBackgroundSecondary
|
||||
GrainTexture(opacity: 0.01)
|
||||
}
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.naturalShadow(.subtle)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(forYouTemplates.enumerated()), id: \.element.id) { index, template in
|
||||
let isSelected = selectedTasks.contains(template.id)
|
||||
let isDisabled = isAtMaxSelection && !isSelected
|
||||
|
||||
ForYouSuggestionRow(
|
||||
template: template,
|
||||
isSelected: isSelected,
|
||||
isDisabled: isDisabled,
|
||||
relevance: index < 3 ? .great : .good,
|
||||
onTap: {
|
||||
withAnimation(.spring(response: 0.2)) {
|
||||
if isSelected {
|
||||
selectedTasks.remove(template.id)
|
||||
} else if !isAtMaxSelection {
|
||||
selectedTasks.insert(template.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if index < forYouTemplates.count - 1 {
|
||||
Divider()
|
||||
.padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(
|
||||
ZStack {
|
||||
Color.appBackgroundSecondary
|
||||
GrainTexture(opacity: 0.01)
|
||||
}
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.naturalShadow(.subtle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - For You Suggestion Row
|
||||
|
||||
private struct ForYouSuggestionRow: View {
|
||||
let template: OnboardingTaskTemplate
|
||||
let isSelected: Bool
|
||||
let isDisabled: Bool
|
||||
let relevance: SuggestionRelevance
|
||||
var onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 14) {
|
||||
// Checkbox
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(isSelected ? template.color : Color.appTextSecondary.opacity(isDisabled ? 0.15 : 0.3), lineWidth: 2)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
if isSelected {
|
||||
Circle()
|
||||
.fill(template.color)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
|
||||
// Task icon
|
||||
Image(systemName: template.icon)
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundColor(template.color.opacity(isDisabled ? 0.3 : 0.8))
|
||||
.frame(width: 24)
|
||||
|
||||
// Task info
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(template.title)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(isDisabled ? Color.appTextSecondary.opacity(0.5) : Color.appTextPrimary)
|
||||
|
||||
Text(template.frequency.capitalized)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary.opacity(isDisabled ? 0.5 : 1))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Relevance badge
|
||||
Text(relevance == .great ? "Great match" : "Good match")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundColor(relevance == .great ? Color.appPrimary : Color.appTextSecondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
(relevance == .great ? Color.appPrimary : Color.appTextSecondary).opacity(0.1)
|
||||
)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isDisabled)
|
||||
.accessibilityLabel("\(template.title), \(template.frequency.capitalized)")
|
||||
.accessibilityValue(isSelected ? "selected" : "not selected")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Legacy wrapper with navigation bar (for backwards compatibility)
|
||||
|
||||
struct OnboardingFirstTaskView: View {
|
||||
|
||||
517
iosApp/iosApp/Onboarding/OnboardingHomeProfileView.swift
Normal file
517
iosApp/iosApp/Onboarding/OnboardingHomeProfileView.swift
Normal file
@@ -0,0 +1,517 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Screen: Home profile — systems, features, exterior, interior
|
||||
struct OnboardingHomeProfileContent: View {
|
||||
var onContinue: () -> Void
|
||||
var onSkip: () -> Void
|
||||
|
||||
@ObservedObject private var onboardingState = OnboardingState.shared
|
||||
@State private var isAnimating = false
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.a11yDecorative()
|
||||
|
||||
// Decorative blobs
|
||||
GeometryReader { geo in
|
||||
OrganicBlobShape(variation: 2)
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appAccent.opacity(0.08),
|
||||
Color.appAccent.opacity(0.02),
|
||||
Color.clear
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: geo.size.width * 0.35
|
||||
)
|
||||
)
|
||||
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.35)
|
||||
.offset(x: -geo.size.width * 0.15, y: geo.size.height * 0.05)
|
||||
.blur(radius: 25)
|
||||
|
||||
OrganicBlobShape(variation: 0)
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(0.06),
|
||||
Color.appPrimary.opacity(0.01),
|
||||
Color.clear
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: geo.size.width * 0.3
|
||||
)
|
||||
)
|
||||
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.3)
|
||||
.offset(x: geo.size.width * 0.55, y: geo.size.height * 0.6)
|
||||
.blur(radius: 20)
|
||||
}
|
||||
.a11yDecorative()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
// Header
|
||||
VStack(spacing: 16) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [Color.appPrimary.opacity(0.15), Color.clear],
|
||||
center: .center,
|
||||
startRadius: 30,
|
||||
endRadius: 80
|
||||
)
|
||||
)
|
||||
.frame(width: 160, height: 160)
|
||||
.offset(x: -20, y: -20)
|
||||
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||
.animation(
|
||||
isAnimating
|
||||
? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
value: isAnimating
|
||||
)
|
||||
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [Color.appAccent.opacity(0.15), Color.clear],
|
||||
center: .center,
|
||||
startRadius: 30,
|
||||
endRadius: 80
|
||||
)
|
||||
)
|
||||
.frame(width: 160, height: 160)
|
||||
.offset(x: 20, y: 20)
|
||||
.scaleEffect(isAnimating ? 0.95 : 1.05)
|
||||
.animation(
|
||||
isAnimating
|
||||
? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5)
|
||||
: .default,
|
||||
value: isAnimating
|
||||
)
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appSecondary],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 90, height: 90)
|
||||
|
||||
Image(systemName: "house.lodge.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.naturalShadow(.pronounced)
|
||||
}
|
||||
|
||||
Text("Tell us about your home")
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.a11yHeader()
|
||||
|
||||
Text("All optional -- helps us personalize your plan")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
.padding(.top, OrganicSpacing.cozy)
|
||||
|
||||
// Systems section
|
||||
ProfileSection(title: "Systems", icon: "gearshape.2.fill", color: .appPrimary) {
|
||||
VStack(spacing: 12) {
|
||||
ProfilePicker(
|
||||
label: "Heating",
|
||||
icon: "flame.fill",
|
||||
selection: $onboardingState.pendingHeatingType,
|
||||
options: HomeProfileOptions.heatingTypes
|
||||
)
|
||||
ProfilePicker(
|
||||
label: "Cooling",
|
||||
icon: "snowflake",
|
||||
selection: $onboardingState.pendingCoolingType,
|
||||
options: HomeProfileOptions.coolingTypes
|
||||
)
|
||||
ProfilePicker(
|
||||
label: "Water Heater",
|
||||
icon: "drop.fill",
|
||||
selection: $onboardingState.pendingWaterHeaterType,
|
||||
options: HomeProfileOptions.waterHeaterTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Features section
|
||||
ProfileSection(title: "Features", icon: "star.fill", color: .appAccent) {
|
||||
HomeFeatureChipGrid(
|
||||
features: [
|
||||
FeatureToggle(label: "Pool", icon: "figure.pool.swim", isOn: $onboardingState.pendingHasPool),
|
||||
FeatureToggle(label: "Sprinklers", icon: "sprinkler.and.droplets.fill", isOn: $onboardingState.pendingHasSprinklerSystem),
|
||||
FeatureToggle(label: "Fireplace", icon: "fireplace.fill", isOn: $onboardingState.pendingHasFireplace),
|
||||
FeatureToggle(label: "Garage", icon: "car.fill", isOn: $onboardingState.pendingHasGarage),
|
||||
FeatureToggle(label: "Basement", icon: "arrow.down.square.fill", isOn: $onboardingState.pendingHasBasement),
|
||||
FeatureToggle(label: "Attic", icon: "arrow.up.square.fill", isOn: $onboardingState.pendingHasAttic),
|
||||
FeatureToggle(label: "Septic", icon: "drop.triangle.fill", isOn: $onboardingState.pendingHasSeptic),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// Exterior section
|
||||
ProfileSection(title: "Exterior", icon: "house.fill", color: Color(hex: "#34C759") ?? .green) {
|
||||
VStack(spacing: 12) {
|
||||
ProfilePicker(
|
||||
label: "Roof Type",
|
||||
icon: "triangle.fill",
|
||||
selection: $onboardingState.pendingRoofType,
|
||||
options: HomeProfileOptions.roofTypes
|
||||
)
|
||||
ProfilePicker(
|
||||
label: "Exterior",
|
||||
icon: "square.stack.3d.up.fill",
|
||||
selection: $onboardingState.pendingExteriorType,
|
||||
options: HomeProfileOptions.exteriorTypes
|
||||
)
|
||||
ProfilePicker(
|
||||
label: "Landscaping",
|
||||
icon: "leaf.fill",
|
||||
selection: $onboardingState.pendingLandscapingType,
|
||||
options: HomeProfileOptions.landscapingTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Interior section
|
||||
ProfileSection(title: "Interior", icon: "sofa.fill", color: Color(hex: "#AF52DE") ?? .purple) {
|
||||
ProfilePicker(
|
||||
label: "Primary Flooring",
|
||||
icon: "square.grid.3x3.fill",
|
||||
selection: $onboardingState.pendingFlooringPrimary,
|
||||
options: HomeProfileOptions.flooringTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 140) // Space for button
|
||||
}
|
||||
|
||||
// Bottom action area
|
||||
VStack(spacing: 14) {
|
||||
Button(action: onContinue) {
|
||||
HStack(spacing: 10) {
|
||||
Text("Continue")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appSecondary],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.naturalShadow(.medium)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
.padding(.bottom, OrganicSpacing.airy)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary],
|
||||
startPoint: .top,
|
||||
endPoint: .center
|
||||
)
|
||||
.frame(height: 60)
|
||||
.offset(y: -60)
|
||||
, alignment: .top
|
||||
)
|
||||
}
|
||||
}
|
||||
.onAppear { isAnimating = true }
|
||||
.onDisappear { isAnimating = false }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Profile Section Card
|
||||
|
||||
private struct ProfileSection<Content: View>: View {
|
||||
let title: String
|
||||
let icon: String
|
||||
let color: Color
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
// Section header
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [color, color.opacity(0.7)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
}
|
||||
|
||||
content
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
ZStack {
|
||||
Color.appBackgroundSecondary
|
||||
GrainTexture(opacity: 0.01)
|
||||
}
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.naturalShadow(.subtle)
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Profile Picker (compact dropdown)
|
||||
|
||||
private struct ProfilePicker: View {
|
||||
let label: String
|
||||
let icon: String
|
||||
@Binding var selection: String?
|
||||
let options: [HomeProfileOption]
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.frame(width: 24)
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Menu {
|
||||
Button("None") {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
selection = nil
|
||||
}
|
||||
}
|
||||
ForEach(options, id: \.value) { option in
|
||||
Button(option.display) {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
selection = option.value
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(displayValue)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(selection != nil ? Color.appPrimary : Color.appTextSecondary)
|
||||
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.fixedSize()
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
(selection != nil ? Color.appPrimary : Color.appTextSecondary).opacity(0.1)
|
||||
)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var displayValue: String {
|
||||
guard let selection = selection else { return "Select" }
|
||||
return options.first { $0.value == selection }?.display ?? selection
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Feature Chip Toggle Grid
|
||||
|
||||
private struct FeatureToggle: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String
|
||||
let icon: String
|
||||
@Binding var isOn: Bool
|
||||
}
|
||||
|
||||
private struct HomeFeatureChipGrid: View {
|
||||
let features: [FeatureToggle]
|
||||
|
||||
var body: some View {
|
||||
FlowLayout(spacing: 10) {
|
||||
ForEach(features) { feature in
|
||||
HomeFeatureChip(
|
||||
label: feature.label,
|
||||
icon: feature.icon,
|
||||
isSelected: feature.isOn,
|
||||
onTap: {
|
||||
withAnimation(.spring(response: 0.2)) {
|
||||
feature.isOn.toggle()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HomeFeatureChip: View {
|
||||
let label: String
|
||||
let icon: String
|
||||
let isSelected: Bool
|
||||
var onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(isSelected ? .white : Color.appTextPrimary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
isSelected
|
||||
? AnyShapeStyle(LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appSecondary],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
))
|
||||
: AnyShapeStyle(Color.appTextSecondary.opacity(0.1))
|
||||
)
|
||||
.clipShape(Capsule())
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(
|
||||
isSelected ? Color.clear : Color.appTextSecondary.opacity(0.2),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(label)
|
||||
.accessibilityValue(isSelected ? "selected" : "not selected")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Home Profile Options
|
||||
|
||||
struct HomeProfileOption {
|
||||
let value: String
|
||||
let display: String
|
||||
}
|
||||
|
||||
enum HomeProfileOptions {
|
||||
static let heatingTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "gas_furnace", display: "Gas Furnace"),
|
||||
HomeProfileOption(value: "electric_furnace", display: "Electric Furnace"),
|
||||
HomeProfileOption(value: "heat_pump", display: "Heat Pump"),
|
||||
HomeProfileOption(value: "boiler", display: "Boiler"),
|
||||
HomeProfileOption(value: "radiant", display: "Radiant"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
]
|
||||
|
||||
static let coolingTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "central_ac", display: "Central AC"),
|
||||
HomeProfileOption(value: "window_ac", display: "Window AC"),
|
||||
HomeProfileOption(value: "heat_pump", display: "Heat Pump"),
|
||||
HomeProfileOption(value: "evaporative", display: "Evaporative"),
|
||||
HomeProfileOption(value: "none", display: "None"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
]
|
||||
|
||||
static let waterHeaterTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "tank_gas", display: "Tank (Gas)"),
|
||||
HomeProfileOption(value: "tank_electric", display: "Tank (Electric)"),
|
||||
HomeProfileOption(value: "tankless_gas", display: "Tankless (Gas)"),
|
||||
HomeProfileOption(value: "tankless_electric", display: "Tankless (Electric)"),
|
||||
HomeProfileOption(value: "heat_pump", display: "Heat Pump"),
|
||||
HomeProfileOption(value: "solar", display: "Solar"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
]
|
||||
|
||||
static let roofTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "asphalt_shingle", display: "Asphalt Shingle"),
|
||||
HomeProfileOption(value: "metal", display: "Metal"),
|
||||
HomeProfileOption(value: "tile", display: "Tile"),
|
||||
HomeProfileOption(value: "slate", display: "Slate"),
|
||||
HomeProfileOption(value: "wood_shake", display: "Wood Shake"),
|
||||
HomeProfileOption(value: "flat", display: "Flat"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
]
|
||||
|
||||
static let exteriorTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "brick", display: "Brick"),
|
||||
HomeProfileOption(value: "vinyl_siding", display: "Vinyl Siding"),
|
||||
HomeProfileOption(value: "wood_siding", display: "Wood Siding"),
|
||||
HomeProfileOption(value: "stucco", display: "Stucco"),
|
||||
HomeProfileOption(value: "stone", display: "Stone"),
|
||||
HomeProfileOption(value: "fiber_cement", display: "Fiber Cement"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
]
|
||||
|
||||
static let flooringTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "hardwood", display: "Hardwood"),
|
||||
HomeProfileOption(value: "laminate", display: "Laminate"),
|
||||
HomeProfileOption(value: "tile", display: "Tile"),
|
||||
HomeProfileOption(value: "carpet", display: "Carpet"),
|
||||
HomeProfileOption(value: "vinyl", display: "Vinyl"),
|
||||
HomeProfileOption(value: "concrete", display: "Concrete"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
]
|
||||
|
||||
static let landscapingTypes: [HomeProfileOption] = [
|
||||
HomeProfileOption(value: "lawn", display: "Lawn"),
|
||||
HomeProfileOption(value: "desert", display: "Desert"),
|
||||
HomeProfileOption(value: "xeriscape", display: "Xeriscape"),
|
||||
HomeProfileOption(value: "garden", display: "Garden"),
|
||||
HomeProfileOption(value: "mixed", display: "Mixed"),
|
||||
HomeProfileOption(value: "none", display: "None"),
|
||||
HomeProfileOption(value: "other", display: "Other"),
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
OnboardingHomeProfileContent(
|
||||
onContinue: {},
|
||||
onSkip: {}
|
||||
)
|
||||
}
|
||||
@@ -45,6 +45,23 @@ class OnboardingState: ObservableObject {
|
||||
/// Whether regional templates are currently loading
|
||||
@Published var isLoadingTemplates: Bool = false
|
||||
|
||||
// MARK: - Home Profile State (collected during onboarding)
|
||||
|
||||
@Published var pendingHeatingType: String? = nil
|
||||
@Published var pendingCoolingType: String? = nil
|
||||
@Published var pendingWaterHeaterType: String? = nil
|
||||
@Published var pendingRoofType: String? = nil
|
||||
@Published var pendingHasPool: Bool = false
|
||||
@Published var pendingHasSprinklerSystem: Bool = false
|
||||
@Published var pendingHasSeptic: Bool = false
|
||||
@Published var pendingHasFireplace: Bool = false
|
||||
@Published var pendingHasGarage: Bool = false
|
||||
@Published var pendingHasBasement: Bool = false
|
||||
@Published var pendingHasAttic: Bool = false
|
||||
@Published var pendingExteriorType: String? = nil
|
||||
@Published var pendingFlooringPrimary: String? = nil
|
||||
@Published var pendingLandscapingType: String? = nil
|
||||
|
||||
/// The user's selected intent (start fresh or join existing).
|
||||
/// Reads/writes the persisted @AppStorage value and notifies SwiftUI of the change.
|
||||
var userIntent: OnboardingIntent {
|
||||
@@ -107,11 +124,13 @@ class OnboardingState: ObservableObject {
|
||||
currentStep = .residenceLocation
|
||||
}
|
||||
case .joinResidence:
|
||||
currentStep = .subscriptionUpsell
|
||||
completeOnboarding()
|
||||
case .residenceLocation:
|
||||
currentStep = .homeProfile
|
||||
case .homeProfile:
|
||||
currentStep = .firstTask
|
||||
case .firstTask:
|
||||
currentStep = .subscriptionUpsell
|
||||
completeOnboarding()
|
||||
case .subscriptionUpsell:
|
||||
completeOnboarding()
|
||||
}
|
||||
@@ -137,6 +156,7 @@ class OnboardingState: ObservableObject {
|
||||
regionalTemplates = []
|
||||
createdResidenceId = nil
|
||||
userIntent = .unknown
|
||||
resetHomeProfile()
|
||||
}
|
||||
|
||||
/// Reset onboarding state (useful for testing or re-onboarding).
|
||||
@@ -150,6 +170,25 @@ class OnboardingState: ObservableObject {
|
||||
createdResidenceId = nil
|
||||
userIntent = .unknown
|
||||
currentStep = .welcome
|
||||
resetHomeProfile()
|
||||
}
|
||||
|
||||
/// Reset all home profile fields
|
||||
private func resetHomeProfile() {
|
||||
pendingHeatingType = nil
|
||||
pendingCoolingType = nil
|
||||
pendingWaterHeaterType = nil
|
||||
pendingRoofType = nil
|
||||
pendingHasPool = false
|
||||
pendingHasSprinklerSystem = false
|
||||
pendingHasSeptic = false
|
||||
pendingHasFireplace = false
|
||||
pendingHasGarage = false
|
||||
pendingHasBasement = false
|
||||
pendingHasAttic = false
|
||||
pendingExteriorType = nil
|
||||
pendingFlooringPrimary = nil
|
||||
pendingLandscapingType = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,8 +201,9 @@ enum OnboardingStep: Int, CaseIterable {
|
||||
case verifyEmail = 4
|
||||
case joinResidence = 5 // Only for users joining with a code
|
||||
case residenceLocation = 6 // ZIP code entry for regional templates
|
||||
case firstTask = 7
|
||||
case subscriptionUpsell = 8
|
||||
case homeProfile = 7 // Home systems & features (optional)
|
||||
case firstTask = 8
|
||||
case subscriptionUpsell = 9
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
@@ -181,6 +221,8 @@ enum OnboardingStep: Int, CaseIterable {
|
||||
return "Join Residence"
|
||||
case .residenceLocation:
|
||||
return "Your Location"
|
||||
case .homeProfile:
|
||||
return "Home Profile"
|
||||
case .firstTask:
|
||||
return "First Task"
|
||||
case .subscriptionUpsell:
|
||||
|
||||
@@ -362,6 +362,20 @@ class ResidenceViewModel: ObservableObject {
|
||||
isActive: true,
|
||||
overdueCount: 0,
|
||||
completionSummary: nil,
|
||||
heatingType: nil,
|
||||
coolingType: nil,
|
||||
waterHeaterType: nil,
|
||||
roofType: nil,
|
||||
hasPool: false,
|
||||
hasSprinklerSystem: false,
|
||||
hasSeptic: false,
|
||||
hasFireplace: false,
|
||||
hasGarage: false,
|
||||
hasBasement: false,
|
||||
hasAttic: false,
|
||||
exteriorType: nil,
|
||||
flooringPrimary: nil,
|
||||
landscapingType: nil,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
)
|
||||
|
||||
@@ -395,7 +395,21 @@ struct ResidenceFormView: View {
|
||||
description: description.isEmpty ? nil : description,
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: KotlinBoolean(bool: isPrimary)
|
||||
isPrimary: KotlinBoolean(bool: isPrimary),
|
||||
heatingType: nil,
|
||||
coolingType: nil,
|
||||
waterHeaterType: nil,
|
||||
roofType: nil,
|
||||
hasPool: nil,
|
||||
hasSprinklerSystem: nil,
|
||||
hasSeptic: nil,
|
||||
hasFireplace: nil,
|
||||
hasGarage: nil,
|
||||
hasBasement: nil,
|
||||
hasAttic: nil,
|
||||
exteriorType: nil,
|
||||
flooringPrimary: nil,
|
||||
landscapingType: nil
|
||||
)
|
||||
|
||||
if let residence = existingResidence {
|
||||
|
||||
@@ -318,6 +318,20 @@ private struct PropertyHeaderBackground: View {
|
||||
isActive: true,
|
||||
overdueCount: 0,
|
||||
completionSummary: nil,
|
||||
heatingType: nil,
|
||||
coolingType: nil,
|
||||
waterHeaterType: nil,
|
||||
roofType: nil,
|
||||
hasPool: false,
|
||||
hasSprinklerSystem: false,
|
||||
hasSeptic: false,
|
||||
hasFireplace: false,
|
||||
hasGarage: false,
|
||||
hasBasement: false,
|
||||
hasAttic: false,
|
||||
exteriorType: nil,
|
||||
flooringPrimary: nil,
|
||||
landscapingType: nil,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z"
|
||||
))
|
||||
|
||||
@@ -308,6 +308,20 @@ private struct CardBackgroundView: View {
|
||||
isActive: true,
|
||||
overdueCount: 2,
|
||||
completionSummary: nil,
|
||||
heatingType: nil,
|
||||
coolingType: nil,
|
||||
waterHeaterType: nil,
|
||||
roofType: nil,
|
||||
hasPool: false,
|
||||
hasSprinklerSystem: false,
|
||||
hasSeptic: false,
|
||||
hasFireplace: false,
|
||||
hasGarage: false,
|
||||
hasBasement: false,
|
||||
hasAttic: false,
|
||||
exteriorType: nil,
|
||||
flooringPrimary: nil,
|
||||
landscapingType: nil,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z"
|
||||
),
|
||||
@@ -341,6 +355,20 @@ private struct CardBackgroundView: View {
|
||||
isActive: true,
|
||||
overdueCount: 0,
|
||||
completionSummary: nil,
|
||||
heatingType: nil,
|
||||
coolingType: nil,
|
||||
waterHeaterType: nil,
|
||||
roofType: nil,
|
||||
hasPool: false,
|
||||
hasSprinklerSystem: false,
|
||||
hasSeptic: false,
|
||||
hasFireplace: false,
|
||||
hasGarage: false,
|
||||
hasBasement: false,
|
||||
hasAttic: false,
|
||||
exteriorType: nil,
|
||||
flooringPrimary: nil,
|
||||
landscapingType: nil,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z"
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user