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:
Trey T
2026-03-30 09:02:27 -05:00
parent 8f86fa2cd0
commit 4609d5a953
18 changed files with 2293 additions and 266 deletions

View File

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

View File

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

View 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: {}
)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
),