- Add complete onboarding flow with 7 screens: Welcome, Name Residence, Value Props, Create Account, Verify Email, First Task, Subscription - Auto-create residence after email verification for "Start Fresh" users - Add predefined task templates (HVAC, Smoke Detectors, Lawn Care, Leaks) that create real tasks with today as due date - Add returning user login button on welcome screen - Update RootView to prioritize onboarding flow for first-time users - Use app icon asset instead of house.fill SF Symbol - Smooth slide transitions with fade-out for back navigation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
321 lines
12 KiB
Swift
321 lines
12 KiB
Swift
import SwiftUI
|
|
import ComposeApp
|
|
|
|
/// Screen 6: First task prompt with suggested templates - Content only (no navigation bar)
|
|
struct OnboardingFirstTaskContent: View {
|
|
var residenceName: String
|
|
var onTaskAdded: () -> Void
|
|
|
|
@StateObject private var viewModel = TaskViewModel()
|
|
@State private var selectedTask: TaskTemplate?
|
|
@State private var isCreatingTask = false
|
|
@State private var showCustomTaskSheet = false
|
|
|
|
private let taskTemplates: [TaskTemplate] = [
|
|
TaskTemplate(
|
|
icon: "fanblades.fill",
|
|
title: "Change HVAC Filter",
|
|
category: "hvac",
|
|
frequency: "monthly",
|
|
color: Color.appPrimary
|
|
),
|
|
TaskTemplate(
|
|
icon: "smoke.fill",
|
|
title: "Check Smoke Detectors",
|
|
category: "safety",
|
|
frequency: "semiannually",
|
|
color: Color.appError
|
|
),
|
|
TaskTemplate(
|
|
icon: "leaf.fill",
|
|
title: "Lawn Care",
|
|
category: "landscaping",
|
|
frequency: "weekly",
|
|
color: Color(hex: "#4CAF50") ?? .green
|
|
),
|
|
TaskTemplate(
|
|
icon: "drop.fill",
|
|
title: "Check for Leaks",
|
|
category: "plumbing",
|
|
frequency: "monthly",
|
|
color: Color.appSecondary
|
|
)
|
|
]
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
ScrollView {
|
|
VStack(spacing: AppSpacing.xl) {
|
|
// Header
|
|
VStack(spacing: AppSpacing.sm) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.appPrimary.opacity(0.1))
|
|
.frame(width: 80, height: 80)
|
|
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 40))
|
|
.foregroundStyle(Color.appPrimary.gradient)
|
|
}
|
|
|
|
Text("Your home is ready!")
|
|
.font(.title2)
|
|
.fontWeight(.bold)
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
Text("What's the first thing you want to track?")
|
|
.font(.subheadline)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding(.top, AppSpacing.lg)
|
|
|
|
// Task templates grid
|
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: AppSpacing.md) {
|
|
ForEach(taskTemplates) { template in
|
|
TaskTemplateCard(
|
|
template: template,
|
|
isSelected: selectedTask?.id == template.id,
|
|
onTap: {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
if selectedTask?.id == template.id {
|
|
selectedTask = nil
|
|
} else {
|
|
selectedTask = template
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.padding(.horizontal, AppSpacing.lg)
|
|
|
|
// Custom task option
|
|
Button(action: {
|
|
showCustomTaskSheet = true
|
|
}) {
|
|
HStack(spacing: AppSpacing.sm) {
|
|
Image(systemName: "plus.circle.fill")
|
|
.font(.title2)
|
|
.foregroundColor(Color.appPrimary)
|
|
|
|
Text("Add Custom Task")
|
|
.font(.headline)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 56)
|
|
.background(Color.appPrimary.opacity(0.1))
|
|
.cornerRadius(AppRadius.md)
|
|
}
|
|
.padding(.horizontal, AppSpacing.lg)
|
|
}
|
|
.padding(.bottom, 120) // Space for button
|
|
}
|
|
|
|
// Bottom action area
|
|
VStack(spacing: AppSpacing.md) {
|
|
if selectedTask != nil {
|
|
Button(action: addSelectedTask) {
|
|
HStack {
|
|
if isCreatingTask {
|
|
ProgressView()
|
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
}
|
|
Text(isCreatingTask ? "Adding Task..." : "Add Task & Continue")
|
|
.font(.headline)
|
|
.fontWeight(.semibold)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 56)
|
|
.foregroundColor(Color.appTextOnPrimary)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.cornerRadius(AppRadius.md)
|
|
.shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5)
|
|
}
|
|
.disabled(isCreatingTask)
|
|
}
|
|
}
|
|
.padding(.horizontal, AppSpacing.xl)
|
|
.padding(.bottom, AppSpacing.xl)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
.frame(height: 40)
|
|
.offset(y: -40)
|
|
, alignment: .top
|
|
)
|
|
}
|
|
.background(Color.appBackgroundPrimary)
|
|
.sheet(isPresented: $showCustomTaskSheet) {
|
|
// TODO: Show custom task form
|
|
Text("Custom Task Form")
|
|
}
|
|
}
|
|
|
|
private func addSelectedTask() {
|
|
guard let template = selectedTask else { return }
|
|
|
|
// Get the first residence from cache (just created during onboarding)
|
|
guard let residences = DataCache.shared.residences.value as? [ResidenceResponse],
|
|
let residence = residences.first else {
|
|
print("🏠 ONBOARDING: No residence found in cache, skipping task creation")
|
|
onTaskAdded()
|
|
return
|
|
}
|
|
|
|
isCreatingTask = true
|
|
|
|
// Look up category ID from DataCache
|
|
let categoryId: Int32? = {
|
|
guard let categories = DataCache.shared.taskCategories.value as? [TaskCategory] else { return nil }
|
|
// Map template category to actual category
|
|
let categoryName = template.category.lowercased()
|
|
return categories.first { $0.name.lowercased() == categoryName }?.id
|
|
}()
|
|
|
|
// Look up frequency ID from DataCache
|
|
let frequencyId: Int32? = {
|
|
guard let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency] else { return nil }
|
|
let frequencyName = template.frequency.lowercased()
|
|
return frequencies.first { $0.name.lowercased() == frequencyName }?.id
|
|
}()
|
|
|
|
// Format today's date as YYYY-MM-DD for the API
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.dateFormat = "yyyy-MM-dd"
|
|
let todayString = dateFormatter.string(from: Date())
|
|
|
|
print("🏠 ONBOARDING: Creating task '\(template.title)' for residence \(residence.id)")
|
|
print("🏠 ONBOARDING: categoryId=\(String(describing: categoryId)), frequencyId=\(String(describing: frequencyId)), dueDate=\(todayString)")
|
|
|
|
let request = TaskCreateRequest(
|
|
residenceId: residence.id,
|
|
title: template.title,
|
|
description: nil,
|
|
categoryId: categoryId.map { KotlinInt(int: $0) },
|
|
priorityId: nil,
|
|
statusId: nil,
|
|
frequencyId: frequencyId.map { KotlinInt(int: $0) },
|
|
assignedToId: nil,
|
|
dueDate: todayString,
|
|
estimatedCost: nil,
|
|
contractorId: nil
|
|
)
|
|
|
|
viewModel.createTask(request: request) { success in
|
|
print("🏠 ONBOARDING: Task creation result: \(success ? "SUCCESS" : "FAILED")")
|
|
self.isCreatingTask = false
|
|
self.onTaskAdded()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Task Template Model
|
|
|
|
struct TaskTemplate: Identifiable {
|
|
let id = UUID()
|
|
let icon: String
|
|
let title: String
|
|
let category: String
|
|
let frequency: String
|
|
let color: Color
|
|
}
|
|
|
|
// MARK: - Task Template Card
|
|
|
|
struct TaskTemplateCard: View {
|
|
let template: TaskTemplate
|
|
let isSelected: Bool
|
|
var onTap: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
VStack(spacing: AppSpacing.sm) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(template.color.opacity(0.1))
|
|
.frame(width: 56, height: 56)
|
|
|
|
Image(systemName: template.icon)
|
|
.font(.title2)
|
|
.foregroundColor(template.color)
|
|
}
|
|
|
|
Text(template.title)
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(Color.appTextPrimary)
|
|
.multilineTextAlignment(.center)
|
|
.lineLimit(2)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Text(template.frequency.capitalized)
|
|
.font(.caption)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(AppSpacing.md)
|
|
.background(Color.appBackgroundSecondary)
|
|
.cornerRadius(AppRadius.lg)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: AppRadius.lg)
|
|
.stroke(isSelected ? template.color : Color.clear, lineWidth: 2)
|
|
)
|
|
.shadow(color: isSelected ? template.color.opacity(0.2) : .clear, radius: 8)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
// MARK: - Legacy wrapper with navigation bar (for backwards compatibility)
|
|
|
|
struct OnboardingFirstTaskView: View {
|
|
var residenceName: String
|
|
var onTaskAdded: () -> Void
|
|
var onSkip: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Navigation bar
|
|
HStack {
|
|
Spacer()
|
|
|
|
Button(action: onSkip) {
|
|
Text("Skip")
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, AppSpacing.lg)
|
|
.padding(.vertical, AppSpacing.md)
|
|
|
|
OnboardingFirstTaskContent(
|
|
residenceName: residenceName,
|
|
onTaskAdded: onTaskAdded
|
|
)
|
|
}
|
|
.background(Color.appBackgroundPrimary)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
OnboardingFirstTaskContent(
|
|
residenceName: "My Home",
|
|
onTaskAdded: {}
|
|
)
|
|
}
|