Both "For You" and "Browse All" tabs are now fully server-driven on iOS and Android. No on-device task list, no client-side scoring rules. When the API fails the screen shows error + Retry + Skip so onboarding can still complete on a flaky network. Shared (KMM) - TaskCreateRequest + TaskResponse carry templateId - New BulkCreateTasksRequest/Response, TaskApi.bulkCreateTasks, APILayer.bulkCreateTasks (updates DataManager + TotalSummary) - OnboardingViewModel: templatesGroupedState + loadTemplatesGrouped; createTasks(residenceId, requests) posts once via the bulk path - Deleted regional-template plumbing: APILayer.getRegionalTemplates, OnboardingViewModel.loadRegionalTemplates, TaskTemplateApi. getTemplatesByRegion, TaskTemplate.regionId/regionName - 5 new AnalyticsEvents constants for the onboarding funnel Android (Compose) - OnboardingFirstTaskContent rewritten against the server catalog; ~70 lines of hardcoded taskCategories gone. Loading / Error / Empty panes with Retry + Skip buttons. Category icons derived from name keywords, colours from a 5-value palette keyed by category id - Browse selection carries template.id into the bulk request so task_template_id is populated server-side iOS (SwiftUI) - New OnboardingTasksViewModel (@MainActor ObservableObject) wrapping APILayer.shared for suggestions / grouped / bulk-submit with loading + error state (mirrors the TaskViewModel.swift pattern) - OnboardingFirstTaskView rewritten: buildForYouSuggestions (130 lines) and fallbackCategories (68 lines) deleted; both tabs show the same error+skip UX as Android; ForYouSuggestion/SuggestionRelevance gone - 5 new AnalyticsEvent cases with identical PostHog event names to the Kotlin constants so cross-platform funnels join cleanly - Existing TaskCreateRequest / TaskResponse call sites in TaskCard, TasksSection, TaskFormView updated for the new templateId parameter Docs - CLAUDE.md gains an "Onboarding task suggestions (server-driven)" subsection covering the data flow, key files on both platforms, and the KotlinInt(int: template.id) wrapping requirement Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
558 lines
23 KiB
Swift
558 lines
23 KiB
Swift
import SwiftUI
|
|
import ComposeApp
|
|
|
|
// MARK: - Field Focus Enum
|
|
enum TaskFormField {
|
|
case title, description, intervalDays, estimatedCost
|
|
}
|
|
|
|
// MARK: - Task Form View
|
|
struct TaskFormView: View {
|
|
let residenceId: Int32?
|
|
let residences: [ResidenceResponse]?
|
|
let existingTask: TaskResponse? // nil for add mode, populated for edit mode
|
|
@Binding var isPresented: Bool
|
|
@StateObject private var viewModel = TaskViewModel()
|
|
@ObservedObject private var dataManager = DataManagerObservable.shared
|
|
@FocusState private var focusedField: TaskFormField?
|
|
|
|
private var isEditMode: Bool {
|
|
existingTask != nil
|
|
}
|
|
|
|
private var needsResidenceSelection: Bool {
|
|
residenceId == nil && !isEditMode
|
|
}
|
|
|
|
private var canSave: Bool {
|
|
!title.isEmpty &&
|
|
(!needsResidenceSelection || selectedResidence != nil) &&
|
|
selectedCategory != nil &&
|
|
selectedFrequency != nil &&
|
|
selectedPriority != nil
|
|
}
|
|
|
|
// Lookups from DataManagerObservable
|
|
private var taskCategories: [TaskCategory] { dataManager.taskCategories }
|
|
private var taskFrequencies: [TaskFrequency] { dataManager.taskFrequencies }
|
|
private var taskPriorities: [TaskPriority] { dataManager.taskPriorities }
|
|
private var isLoadingLookups: Bool { !dataManager.lookupsInitialized }
|
|
|
|
// Form fields
|
|
@State private var selectedResidence: ResidenceResponse?
|
|
@State private var title: String
|
|
@State private var description: String
|
|
@State private var selectedCategory: TaskCategory?
|
|
@State private var selectedFrequency: TaskFrequency?
|
|
@State private var selectedPriority: TaskPriority?
|
|
@State private var inProgress: Bool
|
|
@State private var hasDueDate: Bool
|
|
@State private var dueDate: Date
|
|
@State private var intervalDays: String
|
|
@State private var estimatedCost: String
|
|
|
|
// Initialize form fields based on mode (add vs edit)
|
|
init(residenceId: Int32? = nil, residences: [ResidenceResponse]? = nil, existingTask: TaskResponse? = nil, isPresented: Binding<Bool>) {
|
|
self.residenceId = residenceId
|
|
self.residences = residences
|
|
self.existingTask = existingTask
|
|
self._isPresented = isPresented
|
|
|
|
// Initialize fields from existing task or with defaults
|
|
if let task = existingTask {
|
|
_title = State(initialValue: task.title)
|
|
_description = State(initialValue: task.description_ ?? "")
|
|
_selectedCategory = State(initialValue: task.category)
|
|
_selectedFrequency = State(initialValue: task.frequency)
|
|
_selectedPriority = State(initialValue: task.priority)
|
|
_inProgress = State(initialValue: task.inProgress)
|
|
|
|
// Parse date from string - use effective due date (nextDueDate if set, otherwise dueDate)
|
|
let parsedDate = (task.effectiveDueDate ?? "").toDate()
|
|
_hasDueDate = State(initialValue: parsedDate != nil)
|
|
_dueDate = State(initialValue: parsedDate ?? Date())
|
|
|
|
_intervalDays = State(initialValue: task.customIntervalDays.map { "\($0.int32Value)" } ?? "")
|
|
_estimatedCost = State(initialValue: task.estimatedCost != nil ? String(task.estimatedCost!.doubleValue) : "")
|
|
} else {
|
|
_title = State(initialValue: "")
|
|
_description = State(initialValue: "")
|
|
_inProgress = State(initialValue: false)
|
|
_hasDueDate = State(initialValue: true)
|
|
_dueDate = State(initialValue: Date())
|
|
_intervalDays = State(initialValue: "")
|
|
_estimatedCost = State(initialValue: "")
|
|
}
|
|
}
|
|
|
|
// Validation errors
|
|
@State private var titleError: String = ""
|
|
@State private var residenceError: String = ""
|
|
|
|
// Error alert state
|
|
@State private var errorAlert: ErrorAlertInfo? = nil
|
|
|
|
// Template suggestions
|
|
@State private var showingTemplatesBrowser = false
|
|
@State private var filteredSuggestions: [TaskTemplate] = []
|
|
@State private var showSuggestions = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
// Residence Picker (only if needed)
|
|
if needsResidenceSelection, let residences = residences {
|
|
Section {
|
|
Picker(L10n.Tasks.property, selection: $selectedResidence) {
|
|
ForEach(residences, id: \.id) { residence in
|
|
Text(residence.name).tag(residence as ResidenceResponse?)
|
|
}
|
|
}
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.residencePicker)
|
|
|
|
if !residenceError.isEmpty {
|
|
FieldError(message: residenceError)
|
|
}
|
|
} header: {
|
|
Text(L10n.Tasks.property)
|
|
}
|
|
.sectionBackground()
|
|
}
|
|
|
|
// Browse Templates Button (only for new tasks)
|
|
if !isEditMode {
|
|
Section {
|
|
Button {
|
|
showingTemplatesBrowser = true
|
|
} label: {
|
|
HStack(spacing: 14) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.appPrimary.opacity(0.12))
|
|
.frame(width: 40, height: 40)
|
|
Image(systemName: "list.bullet.rectangle")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Browse Task Templates")
|
|
.font(.system(size: 16, weight: .semibold, design: .rounded))
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
Text("\(dataManager.taskTemplateCount) common tasks")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
} header: {
|
|
Text("Quick Start")
|
|
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
|
} footer: {
|
|
Text("Choose from common home maintenance tasks or create your own below")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
.sectionBackground()
|
|
}
|
|
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
TextField(L10n.Tasks.titleLabel, text: $title)
|
|
.focused($focusedField, equals: .title)
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.titleField)
|
|
.onChange(of: title) { _, newValue in
|
|
updateSuggestions(query: newValue)
|
|
}
|
|
|
|
// Inline suggestions dropdown
|
|
if showSuggestions && !filteredSuggestions.isEmpty && focusedField == .title {
|
|
TaskSuggestionsView(
|
|
suggestions: filteredSuggestions,
|
|
onSelect: { template in
|
|
selectTaskTemplate(template)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
if !titleError.isEmpty {
|
|
FieldError(message: titleError)
|
|
}
|
|
|
|
TextField(L10n.Tasks.descriptionOptional, text: $description, axis: .vertical)
|
|
.lineLimit(3...6)
|
|
.focused($focusedField, equals: .description)
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.descriptionField)
|
|
} header: {
|
|
Text(L10n.Tasks.taskDetails)
|
|
.accessibilityAddTraits(.isHeader)
|
|
} footer: {
|
|
Text(L10n.Tasks.titleRequired)
|
|
.font(.caption)
|
|
.foregroundColor(Color.appError)
|
|
}
|
|
.sectionBackground()
|
|
|
|
Section {
|
|
Picker(L10n.Tasks.category, selection: $selectedCategory) {
|
|
Text(L10n.Tasks.selectCategory).tag(nil as TaskCategory?)
|
|
ForEach(taskCategories, id: \.id) { category in
|
|
Text(category.name.capitalized).tag(category as TaskCategory?)
|
|
}
|
|
}
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.categoryPicker)
|
|
} header: {
|
|
Text(L10n.Tasks.category)
|
|
}
|
|
.sectionBackground()
|
|
|
|
Section {
|
|
Picker(L10n.Tasks.frequency, selection: $selectedFrequency) {
|
|
Text(L10n.Tasks.selectFrequency).tag(nil as TaskFrequency?)
|
|
ForEach(taskFrequencies, id: \.id) { frequency in
|
|
Text(frequency.displayName).tag(frequency as TaskFrequency?)
|
|
}
|
|
}
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.frequencyPicker)
|
|
.onChange(of: selectedFrequency) { _, newFrequency in
|
|
// Clear interval days if not Custom frequency
|
|
if newFrequency?.name.lowercased() != "custom" {
|
|
intervalDays = ""
|
|
}
|
|
}
|
|
|
|
// Show custom interval field only for "Custom" frequency
|
|
if selectedFrequency?.name.lowercased() == "custom" {
|
|
TextField(L10n.Tasks.customInterval, text: $intervalDays)
|
|
.keyboardType(.numberPad)
|
|
.focused($focusedField, equals: .intervalDays)
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.intervalDaysField)
|
|
}
|
|
|
|
Toggle(L10n.Tasks.dueDate, isOn: $hasDueDate)
|
|
|
|
if hasDueDate {
|
|
DatePicker(L10n.Tasks.dueDate, selection: $dueDate, displayedComponents: .date)
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.dueDatePicker)
|
|
}
|
|
} header: {
|
|
Text(L10n.Tasks.scheduling)
|
|
.accessibilityAddTraits(.isHeader)
|
|
} footer: {
|
|
if selectedFrequency?.name.lowercased() == "custom" {
|
|
Text("Enter the number of days between each occurrence")
|
|
.font(.caption)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
}
|
|
.sectionBackground()
|
|
|
|
Section {
|
|
Picker(L10n.Tasks.priority, selection: $selectedPriority) {
|
|
Text(L10n.Tasks.selectPriority).tag(nil as TaskPriority?)
|
|
ForEach(taskPriorities, id: \.id) { priority in
|
|
Text(priority.displayName).tag(priority as TaskPriority?)
|
|
}
|
|
}
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.priorityPicker)
|
|
|
|
Toggle(L10n.Tasks.inProgressLabel, isOn: $inProgress)
|
|
} header: {
|
|
Text(L10n.Tasks.priorityAndStatus)
|
|
.accessibilityAddTraits(.isHeader)
|
|
}
|
|
.sectionBackground()
|
|
|
|
Section(header: Text(L10n.Tasks.cost)) {
|
|
TextField(L10n.Tasks.estimatedCost, text: $estimatedCost)
|
|
.keyboardType(.decimalPad)
|
|
.focused($focusedField, equals: .estimatedCost)
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.estimatedCostField)
|
|
}
|
|
.sectionBackground()
|
|
|
|
if let errorMessage = viewModel.errorMessage {
|
|
Section {
|
|
Text(errorMessage)
|
|
.foregroundColor(Color.appError)
|
|
.font(.caption)
|
|
}
|
|
.sectionBackground()
|
|
}
|
|
}
|
|
.loadingOverlay(isLoading: isLoadingLookups, message: L10n.Tasks.loading)
|
|
.standardFormStyle()
|
|
.navigationTitle(isEditMode ? L10n.Tasks.editTitle : L10n.Tasks.addTitle)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button(L10n.Common.cancel) {
|
|
isPresented = false
|
|
}
|
|
.disabled(isLoadingLookups)
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.formCancelButton)
|
|
}
|
|
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button(isEditMode ? L10n.Common.save : L10n.Common.add) {
|
|
submitForm()
|
|
}
|
|
.disabled(!canSave || viewModel.isLoading || isLoadingLookups)
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.saveButton)
|
|
}
|
|
|
|
ToolbarItemGroup(placement: .keyboard) {
|
|
Spacer()
|
|
Button("Done") {
|
|
focusedField = nil
|
|
}
|
|
.foregroundColor(Color.appPrimary)
|
|
.fontWeight(.medium)
|
|
}
|
|
}
|
|
.onAppear {
|
|
// Track screen view for new tasks
|
|
if !isEditMode {
|
|
AnalyticsManager.shared.trackScreen(.newTask)
|
|
}
|
|
// Set defaults when lookups are available
|
|
if dataManager.lookupsInitialized {
|
|
setDefaults()
|
|
}
|
|
}
|
|
.onChange(of: dataManager.lookupsInitialized) { _, initialized in
|
|
if initialized {
|
|
setDefaults()
|
|
}
|
|
}
|
|
.onChange(of: viewModel.taskCreated) { _, created in
|
|
if created {
|
|
viewModel.resetActionState()
|
|
isPresented = false
|
|
}
|
|
}
|
|
.onChange(of: viewModel.errorMessage) { _, errorMessage in
|
|
if let errorMessage = errorMessage, !errorMessage.isEmpty {
|
|
errorAlert = ErrorAlertInfo(message: errorMessage)
|
|
}
|
|
}
|
|
.errorAlert(
|
|
error: errorAlert,
|
|
onRetry: {
|
|
errorAlert = nil
|
|
submitForm()
|
|
},
|
|
onDismiss: {
|
|
errorAlert = nil
|
|
}
|
|
)
|
|
.sheet(isPresented: $showingTemplatesBrowser) {
|
|
TaskTemplatesBrowserView { template in
|
|
selectTaskTemplate(template)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Suggestions Helpers
|
|
|
|
private func updateSuggestions(query: String) {
|
|
if query.count >= 2 {
|
|
filteredSuggestions = dataManager.searchTaskTemplates(query: query)
|
|
showSuggestions = !filteredSuggestions.isEmpty
|
|
} else {
|
|
filteredSuggestions = []
|
|
showSuggestions = false
|
|
}
|
|
}
|
|
|
|
private func selectTaskTemplate(_ template: TaskTemplate) {
|
|
// Fill in the title
|
|
title = template.title
|
|
|
|
// Fill in description if available
|
|
description = template.description_
|
|
|
|
// Auto-select matching category by ID or name
|
|
if let categoryId = template.categoryId {
|
|
selectedCategory = taskCategories.first(where: { $0.id == Int(categoryId.int32Value) })
|
|
} else if let category = template.category {
|
|
selectedCategory = taskCategories.first(where: { $0.name.lowercased() == category.name.lowercased() })
|
|
}
|
|
|
|
// Auto-select matching frequency by ID or name
|
|
if let frequencyId = template.frequencyId {
|
|
selectedFrequency = taskFrequencies.first(where: { $0.id == Int(frequencyId.int32Value) })
|
|
} else if let frequency = template.frequency {
|
|
selectedFrequency = taskFrequencies.first(where: { $0.name.lowercased() == frequency.name.lowercased() })
|
|
}
|
|
|
|
// Clear suggestions and dismiss keyboard
|
|
showSuggestions = false
|
|
filteredSuggestions = []
|
|
focusedField = nil
|
|
}
|
|
|
|
private func setDefaults() {
|
|
// Set default values if not already set
|
|
if selectedCategory == nil && !taskCategories.isEmpty {
|
|
selectedCategory = taskCategories.first
|
|
}
|
|
|
|
if selectedFrequency == nil && !taskFrequencies.isEmpty {
|
|
// Default to "once"
|
|
selectedFrequency = taskFrequencies.first { $0.name == "once" } ?? taskFrequencies.first
|
|
}
|
|
|
|
if selectedPriority == nil && !taskPriorities.isEmpty {
|
|
// Default to "medium"
|
|
selectedPriority = taskPriorities.first { $0.name == "medium" } ?? taskPriorities.first
|
|
}
|
|
|
|
// Set default residence if provided
|
|
if needsResidenceSelection && selectedResidence == nil, let residences = residences, !residences.isEmpty {
|
|
selectedResidence = residences.first
|
|
}
|
|
}
|
|
|
|
private func validateForm() -> Bool {
|
|
var isValid = true
|
|
|
|
if title.isEmpty {
|
|
titleError = "Title is required"
|
|
isValid = false
|
|
} else {
|
|
titleError = ""
|
|
}
|
|
|
|
if needsResidenceSelection && selectedResidence == nil {
|
|
residenceError = "Property is required"
|
|
isValid = false
|
|
} else {
|
|
residenceError = ""
|
|
}
|
|
|
|
if selectedCategory == nil {
|
|
viewModel.errorMessage = "Please select a category"
|
|
isValid = false
|
|
}
|
|
|
|
if selectedFrequency == nil {
|
|
viewModel.errorMessage = "Please select a frequency"
|
|
isValid = false
|
|
}
|
|
|
|
if selectedPriority == nil {
|
|
viewModel.errorMessage = "Please select a priority"
|
|
isValid = false
|
|
}
|
|
|
|
if !intervalDays.isEmpty, Int32(intervalDays) == nil {
|
|
viewModel.errorMessage = "Custom interval must be a valid number"
|
|
isValid = false
|
|
}
|
|
|
|
return isValid
|
|
}
|
|
|
|
private func submitForm() {
|
|
guard validateForm() else { return }
|
|
|
|
guard let category = selectedCategory,
|
|
let frequency = selectedFrequency,
|
|
let priority = selectedPriority else {
|
|
return
|
|
}
|
|
|
|
// Format date as yyyy-MM-dd using extension, or nil if no due date
|
|
let dueDateString: String? = hasDueDate ? dueDate.formattedAPI() : nil
|
|
|
|
if isEditMode, let task = existingTask {
|
|
// UPDATE existing task
|
|
// Include customIntervalDays only for "Custom" frequency
|
|
let customInterval: KotlinInt? = frequency.name.lowercased() == "custom" && !intervalDays.isEmpty
|
|
? KotlinInt(int: Int32(intervalDays) ?? 0)
|
|
: nil
|
|
|
|
let request = TaskCreateRequest(
|
|
residenceId: task.residenceId,
|
|
title: title,
|
|
description: description.isEmpty ? nil : description,
|
|
categoryId: KotlinInt(int: Int32(category.id)),
|
|
priorityId: KotlinInt(int: Int32(priority.id)),
|
|
inProgress: inProgress,
|
|
frequencyId: KotlinInt(int: Int32(frequency.id)),
|
|
customIntervalDays: customInterval,
|
|
assignedToId: nil,
|
|
dueDate: dueDateString,
|
|
estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0),
|
|
contractorId: nil,
|
|
templateId: nil
|
|
)
|
|
|
|
viewModel.updateTask(id: task.id, request: request) { success in
|
|
if success {
|
|
isPresented = false
|
|
}
|
|
}
|
|
} else {
|
|
// CREATE new task
|
|
// Determine the actual residence ID to use
|
|
let actualResidenceId: Int32
|
|
if let providedId = residenceId {
|
|
actualResidenceId = providedId
|
|
} else if let selected = selectedResidence {
|
|
actualResidenceId = Int32(selected.id)
|
|
} else {
|
|
return
|
|
}
|
|
|
|
// Include customIntervalDays only for "Custom" frequency
|
|
let customIntervalCreate: KotlinInt? = frequency.name.lowercased() == "custom" && !intervalDays.isEmpty
|
|
? KotlinInt(int: Int32(intervalDays) ?? 0)
|
|
: nil
|
|
|
|
let request = TaskCreateRequest(
|
|
residenceId: actualResidenceId,
|
|
title: title,
|
|
description: description.isEmpty ? nil : description,
|
|
categoryId: KotlinInt(int: Int32(category.id)),
|
|
priorityId: KotlinInt(int: Int32(priority.id)),
|
|
inProgress: inProgress,
|
|
frequencyId: KotlinInt(int: Int32(frequency.id)),
|
|
customIntervalDays: customIntervalCreate,
|
|
assignedToId: nil,
|
|
dueDate: dueDateString,
|
|
estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0),
|
|
contractorId: nil,
|
|
templateId: nil
|
|
)
|
|
|
|
viewModel.createTask(request: request) { success in
|
|
if success {
|
|
// Track task creation
|
|
AnalyticsManager.shared.track(.taskCreated(residenceId: actualResidenceId))
|
|
// View will dismiss automatically via onChange
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview("With Residence ID") {
|
|
TaskFormView(residenceId: 1, residences: nil, isPresented: .constant(true))
|
|
}
|
|
|
|
#Preview("With Residence Selection") {
|
|
TaskFormView(residenceId: nil, residences: [], isPresented: .constant(true))
|
|
}
|