Files
honeyDueKMP/iosApp/iosApp/Task/TaskFormView.swift
Trey t 9c574c4343 Harden iOS app with audit fixes, UI consistency, and sheet race condition fixes
Applies verified fixes from deep audit (concurrency, performance, security,
accessibility), standardizes CRUD form buttons to Add/Save pattern, removes
.drawingGroup() that broke search bar TextFields, and converts vulnerable
.sheet(isPresented:) + if-let patterns to safe presentation to prevent
blank white modals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:59:56 -06:00

533 lines
22 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 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)
_dueDate = State(initialValue: (task.effectiveDueDate ?? "").toDate() ?? 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)
_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?)
}
}
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)
.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)
} header: {
Text(L10n.Tasks.taskDetails)
} 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?)
}
}
} 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?)
}
}
.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)
}
DatePicker(L10n.Tasks.dueDate, selection: $dueDate, displayedComponents: .date)
} header: {
Text(L10n.Tasks.scheduling)
} 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?)
}
}
Toggle(L10n.Tasks.inProgressLabel, isOn: $inProgress)
} header: {
Text(L10n.Tasks.priorityAndStatus)
}
.sectionBackground()
Section(header: Text(L10n.Tasks.cost)) {
TextField(L10n.Tasks.estimatedCost, text: $estimatedCost)
.keyboardType(.decimalPad)
.focused($focusedField, equals: .estimatedCost)
}
.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)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(isEditMode ? L10n.Common.save : L10n.Common.add) {
submitForm()
}
.disabled(!canSave || viewModel.isLoading || isLoadingLookups)
}
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 {
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
let dueDateString = dueDate.formattedAPI()
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
)
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
)
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))
}