Files
honeyDueKMP/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift
Trey t 4a04aff1e6 Replace status_id with in_progress boolean across mobile apps
- Remove TaskStatus model and status_id foreign key references
- Add in_progress boolean field to task models and forms
- Update TaskApi to use dedicated POST endpoints for task actions:
  - POST /tasks/:id/cancel/ instead of PATCH with is_cancelled
  - POST /tasks/:id/uncancel/
  - POST /tasks/:id/archive/
  - POST /tasks/:id/unarchive/
- Fix iOS TaskViewModel to use error-first pattern for Kotlin-Swift
  generic type bridging issues
- Update iOS callback signatures to pass full TaskResponse instead
  of just taskId to avoid stale closure lookups
- Add in_progress localization strings
- Update widget preview data to use inProgress boolean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 20:47:59 -06:00

601 lines
26 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()
@ObservedObject private var dataManager = DataManagerObservable.shared
@ObservedObject private var onboardingState = OnboardingState.shared
@State private var selectedTasks: Set<UUID> = []
@State private var isCreatingTasks = false
@State private var showCustomTaskSheet = false
@State private var expandedCategory: String? = nil
/// Maximum tasks allowed for free tier (matches API TierLimits)
private let maxTasksAllowed = 5
private let taskCategories: [OnboardingTaskCategory] = [
OnboardingTaskCategory(
name: "HVAC & Climate",
icon: "thermometer.medium",
color: Color.appPrimary,
tasks: [
OnboardingTaskTemplate(icon: "fanblades.fill", title: "Change HVAC Filter", category: "hvac", frequency: "monthly", color: Color.appPrimary),
OnboardingTaskTemplate(icon: "air.conditioner.horizontal.fill", title: "Schedule AC Tune-Up", category: "hvac", frequency: "yearly", color: Color.appPrimary),
OnboardingTaskTemplate(icon: "flame.fill", title: "Inspect Furnace", category: "hvac", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange),
OnboardingTaskTemplate(icon: "wind", title: "Clean Air Ducts", category: "hvac", frequency: "yearly", color: Color.appSecondary)
]
),
OnboardingTaskCategory(
name: "Safety & Security",
icon: "shield.checkered",
color: Color.appError,
tasks: [
OnboardingTaskTemplate(icon: "smoke.fill", title: "Test Smoke Detectors", category: "safety", frequency: "monthly", color: Color.appError),
OnboardingTaskTemplate(icon: "dot.radiowaves.left.and.right", title: "Check CO Detectors", category: "safety", frequency: "monthly", color: Color.appError),
OnboardingTaskTemplate(icon: "flame.fill", title: "Inspect Fire Extinguisher", category: "safety", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange),
OnboardingTaskTemplate(icon: "lock.fill", title: "Test Garage Door Safety", category: "safety", frequency: "monthly", color: Color.appSecondary)
]
),
OnboardingTaskCategory(
name: "Plumbing",
icon: "drop.fill",
color: Color.appSecondary,
tasks: [
OnboardingTaskTemplate(icon: "drop.fill", title: "Check for Leaks", category: "plumbing", frequency: "monthly", color: Color.appSecondary),
OnboardingTaskTemplate(icon: "bolt.horizontal.fill", title: "Flush Water Heater", category: "plumbing", frequency: "yearly", color: Color(hex: "#FF9500") ?? .orange),
OnboardingTaskTemplate(icon: "wrench.and.screwdriver.fill", title: "Clean Faucet Aerators", category: "plumbing", frequency: "quarterly", color: Color.appPrimary),
OnboardingTaskTemplate(icon: "arrow.down.circle.fill", title: "Snake Drains", category: "plumbing", frequency: "quarterly", color: Color.appTextSecondary)
]
),
OnboardingTaskCategory(
name: "Outdoor & Lawn",
icon: "leaf.fill",
color: Color(hex: "#34C759") ?? .green,
tasks: [
OnboardingTaskTemplate(icon: "leaf.fill", title: "Lawn Care", category: "landscaping", frequency: "weekly", color: Color(hex: "#34C759") ?? .green),
OnboardingTaskTemplate(icon: "cloud.rain.fill", title: "Clean Gutters", category: "exterior", frequency: "semiannually", color: Color.appSecondary),
OnboardingTaskTemplate(icon: "sun.max.fill", title: "Check Sprinkler System", category: "landscaping", frequency: "monthly", color: Color(hex: "#FF9500") ?? .orange),
OnboardingTaskTemplate(icon: "scissors", title: "Trim Trees & Shrubs", category: "landscaping", frequency: "quarterly", color: Color(hex: "#34C759") ?? .green)
]
),
OnboardingTaskCategory(
name: "Appliances",
icon: "refrigerator.fill",
color: Color.appAccent,
tasks: [
OnboardingTaskTemplate(icon: "refrigerator.fill", title: "Clean Refrigerator Coils", category: "appliances", frequency: "semiannually", color: Color.appAccent),
OnboardingTaskTemplate(icon: "washer.fill", title: "Clean Washing Machine", category: "appliances", frequency: "monthly", color: Color.appSecondary),
OnboardingTaskTemplate(icon: "dishwasher.fill", title: "Clean Dishwasher Filter", category: "appliances", frequency: "monthly", color: Color.appPrimary),
OnboardingTaskTemplate(icon: "oven.fill", title: "Deep Clean Oven", category: "appliances", frequency: "quarterly", color: Color(hex: "#FF6B35") ?? .orange)
]
),
OnboardingTaskCategory(
name: "General Home",
icon: "house.fill",
color: Color(hex: "#AF52DE") ?? .purple,
tasks: [
OnboardingTaskTemplate(icon: "paintbrush.fill", title: "Touch Up Paint", category: "interior", frequency: "yearly", color: Color(hex: "#AF52DE") ?? .purple),
OnboardingTaskTemplate(icon: "lightbulb.fill", title: "Replace Light Bulbs", category: "electrical", frequency: "monthly", color: Color.appAccent),
OnboardingTaskTemplate(icon: "door.left.hand.closed", title: "Lubricate Door Hinges", category: "interior", frequency: "yearly", color: Color.appTextSecondary),
OnboardingTaskTemplate(icon: "window.vertical.closed", title: "Clean Window Tracks", category: "interior", frequency: "semiannually", color: Color.appPrimary)
]
)
]
private var allTasks: [OnboardingTaskTemplate] {
taskCategories.flatMap { $0.tasks }
}
private var selectedCount: Int {
selectedTasks.count
}
private var isAtMaxSelection: Bool {
selectedTasks.count >= maxTasksAllowed
}
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(spacing: AppSpacing.xl) {
// Header with celebration
VStack(spacing: AppSpacing.md) {
ZStack {
// Celebration circles
Circle()
.fill(
RadialGradient(
colors: [Color.appPrimary.opacity(0.2), Color.clear],
center: .center,
startRadius: 30,
endRadius: 80
)
)
.frame(width: 140, height: 140)
.offset(x: -15, y: -15)
Circle()
.fill(
RadialGradient(
colors: [Color.appAccent.opacity(0.2), Color.clear],
center: .center,
startRadius: 30,
endRadius: 80
)
)
.frame(width: 140, height: 140)
.offset(x: 15, y: 15)
// Party icon
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appPrimary, Color.appSecondary],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
Image(systemName: "party.popper.fill")
.font(.system(size: 36))
.foregroundColor(.white)
}
.shadow(color: Color.appPrimary.opacity(0.4), radius: 15, y: 8)
}
Text("You're all set up!")
.font(.title)
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary)
Text("Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
}
.padding(.top, AppSpacing.lg)
// Selection counter chip
HStack(spacing: AppSpacing.sm) {
Image(systemName: isAtMaxSelection ? "checkmark.seal.fill" : "checkmark.circle.fill")
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
Text("\(selectedCount)/\(maxTasksAllowed) tasks selected")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
}
.padding(.horizontal, AppSpacing.lg)
.padding(.vertical, AppSpacing.sm)
.background((isAtMaxSelection ? Color.appAccent : Color.appPrimary).opacity(0.1))
.cornerRadius(AppRadius.xl)
.animation(.spring(response: 0.3), value: selectedCount)
// Task categories
VStack(spacing: AppSpacing.md) {
ForEach(taskCategories) { category in
TaskCategorySection(
category: category,
selectedTasks: $selectedTasks,
isExpanded: expandedCategory == category.name,
isAtMaxSelection: isAtMaxSelection,
onToggleExpand: {
withAnimation(.spring(response: 0.3)) {
if expandedCategory == category.name {
expandedCategory = nil
} else {
expandedCategory = category.name
}
}
}
)
}
}
.padding(.horizontal, AppSpacing.lg)
// Quick add all popular
Button(action: selectPopularTasks) {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "sparkles")
.font(.headline)
Text("Add Most Popular")
.font(.headline)
.fontWeight(.medium)
}
.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
)
)
.cornerRadius(AppRadius.lg)
.overlay(
RoundedRectangle(cornerRadius: AppRadius.lg)
.stroke(
LinearGradient(
colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)],
startPoint: .leading,
endPoint: .trailing
),
lineWidth: 1.5
)
)
}
.padding(.horizontal, AppSpacing.lg)
}
.padding(.bottom, 140) // Space for button
}
// Bottom action area
VStack(spacing: AppSpacing.md) {
Button(action: addSelectedTasks) {
HStack(spacing: AppSpacing.sm) {
if isCreatingTasks {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Text(selectedCount > 0 ? "Add \(selectedCount) Task\(selectedCount == 1 ? "" : "s") & Continue" : "Skip for Now")
.font(.headline)
.fontWeight(.bold)
Image(systemName: "arrow.right")
.font(.headline)
}
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
selectedCount > 0
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing))
: AnyShapeStyle(Color.appTextSecondary.opacity(0.5))
)
.cornerRadius(AppRadius.lg)
.shadow(color: selectedCount > 0 ? Color.appPrimary.opacity(0.4) : .clear, radius: 15, y: 8)
}
.disabled(isCreatingTasks)
.animation(.easeInOut(duration: 0.2), value: selectedCount)
}
.padding(.horizontal, AppSpacing.xl)
.padding(.bottom, AppSpacing.xxxl)
.background(
LinearGradient(
colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary],
startPoint: .top,
endPoint: .center
)
.frame(height: 60)
.offset(y: -60)
, alignment: .top
)
}
.background(Color.appBackgroundPrimary)
.onAppear {
// Expand first category by default
expandedCategory = taskCategories.first?.name
}
}
private func selectPopularTasks() {
// Select top popular tasks (up to max allowed)
let popularTaskTitles = [
"Change HVAC Filter",
"Test Smoke Detectors",
"Check for Leaks",
"Clean Gutters",
"Clean Refrigerator Coils"
]
withAnimation(.spring(response: 0.3)) {
for task in allTasks where popularTaskTitles.contains(task.title) {
if selectedTasks.count < maxTasksAllowed {
selectedTasks.insert(task.id)
}
}
}
}
private func addSelectedTasks() {
// If no tasks selected, just skip
if selectedTasks.isEmpty {
onTaskAdded()
return
}
// Get the residence ID from OnboardingState (set during residence creation)
guard let residenceId = onboardingState.createdResidenceId else {
print("🏠 ONBOARDING: No residence ID found in OnboardingState, skipping task creation")
onTaskAdded()
return
}
isCreatingTasks = true
let selectedTemplates = allTasks.filter { selectedTasks.contains($0.id) }
var completedCount = 0
let totalCount = selectedTemplates.count
// Format today's date as YYYY-MM-DD for the API
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
let todayString = dateFormatter.string(from: Date())
print("🏠 ONBOARDING: Creating \(totalCount) tasks for residence \(residenceId)")
for template in selectedTemplates {
// Look up category ID from DataManager
let categoryId: Int32? = {
let categoryName = template.category.lowercased()
return dataManager.taskCategories.first { $0.name.lowercased() == categoryName }?.id
}()
// Look up frequency ID from DataManager
let frequencyId: Int32? = {
let frequencyName = template.frequency.lowercased()
return dataManager.taskFrequencies.first { $0.name.lowercased() == frequencyName }?.id
}()
print("🏠 ONBOARDING: Creating task '\(template.title)' - categoryId=\(String(describing: categoryId)), frequencyId=\(String(describing: frequencyId))")
let request = TaskCreateRequest(
residenceId: residenceId,
title: template.title,
description: nil,
categoryId: categoryId.map { KotlinInt(int: $0) },
priorityId: nil,
inProgress: false,
frequencyId: frequencyId.map { KotlinInt(int: $0) },
assignedToId: nil,
dueDate: todayString,
estimatedCost: nil,
contractorId: nil
)
viewModel.createTask(request: request) { success in
completedCount += 1
print("🏠 ONBOARDING: Task '\(template.title)' creation: \(success ? "SUCCESS" : "FAILED") (\(completedCount)/\(totalCount))")
if completedCount == totalCount {
self.isCreatingTasks = false
self.onTaskAdded()
}
}
}
}
}
// MARK: - Onboarding Task Category Model
struct OnboardingTaskCategory: Identifiable {
let id = UUID()
let name: String
let icon: String
let color: Color
let tasks: [OnboardingTaskTemplate]
}
// MARK: - Task Category Section
struct TaskCategorySection: View {
let category: OnboardingTaskCategory
@Binding var selectedTasks: Set<UUID>
let isExpanded: Bool
let isAtMaxSelection: Bool
var onToggleExpand: () -> Void
private var selectedInCategory: Int {
category.tasks.filter { selectedTasks.contains($0.id) }.count
}
var body: some View {
VStack(spacing: 0) {
// Category header
Button(action: onToggleExpand) {
HStack(spacing: AppSpacing.md) {
// Category icon
ZStack {
Circle()
.fill(
LinearGradient(
colors: [category.color, category.color.opacity(0.7)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 44, height: 44)
Image(systemName: category.icon)
.font(.title3)
.foregroundColor(.white)
}
// Category name
Text(category.name)
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary)
Spacer()
// Selection badge
if selectedInCategory > 0 {
Text("\(selectedInCategory)")
.font(.caption)
.fontWeight(.bold)
.foregroundColor(.white)
.frame(width: 24, height: 24)
.background(category.color)
.clipShape(Circle())
}
// Chevron
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(Color.appTextSecondary)
}
.padding(AppSpacing.md)
.background(Color.appBackgroundSecondary)
.cornerRadius(isExpanded ? AppRadius.lg : AppRadius.lg, corners: isExpanded ? [.topLeft, .topRight] : .allCorners)
}
.buttonStyle(.plain)
// Expanded tasks
if isExpanded {
VStack(spacing: 0) {
ForEach(category.tasks) { task in
let taskIsSelected = selectedTasks.contains(task.id)
OnboardingTaskTemplateRow(
template: task,
isSelected: taskIsSelected,
isDisabled: isAtMaxSelection && !taskIsSelected,
onTap: {
withAnimation(.spring(response: 0.2)) {
if taskIsSelected {
selectedTasks.remove(task.id)
} else if !isAtMaxSelection {
selectedTasks.insert(task.id)
}
}
}
)
if task.id != category.tasks.last?.id {
Divider()
.padding(.leading, 60)
}
}
}
.background(Color.appBackgroundSecondary.opacity(0.5))
.cornerRadius(AppRadius.lg, corners: [.bottomLeft, .bottomRight])
}
}
.shadow(color: Color.black.opacity(0.05), radius: 8, y: 4)
}
}
// MARK: - Task Template Row
struct OnboardingTaskTemplateRow: View {
let template: OnboardingTaskTemplate
let isSelected: Bool
let isDisabled: Bool
var onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: AppSpacing.md) {
// Checkbox
ZStack {
Circle()
.stroke(isSelected ? template.color : Color.appTextSecondary.opacity(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(.caption)
.fontWeight(.bold)
.foregroundColor(.white)
}
}
// Task info
VStack(alignment: .leading, spacing: 2) {
Text(template.title)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(isDisabled ? Color.appTextSecondary.opacity(0.5) : Color.appTextPrimary)
Text(template.frequency.capitalized)
.font(.caption)
.foregroundColor(Color.appTextSecondary.opacity(isDisabled ? 0.5 : 1))
}
Spacer()
// Task icon
Image(systemName: template.icon)
.font(.title3)
.foregroundColor(template.color.opacity(isDisabled ? 0.3 : 0.6))
}
.padding(.horizontal, AppSpacing.md)
.padding(.vertical, AppSpacing.sm)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(isDisabled)
}
}
// MARK: - Onboarding Task Template Model
struct OnboardingTaskTemplate: Identifiable {
let id = UUID()
let icon: String
let title: String
let category: String
let frequency: String
let color: Color
}
// 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: {}
)
}