- 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>
526 lines
21 KiB
Swift
526 lines
21 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
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
_dueDate = State(initialValue: formatter.date(from: task.dueDate ?? "") ?? Date())
|
|
|
|
_intervalDays = State(initialValue: "") // No longer in API
|
|
_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 {
|
|
ZStack {
|
|
Form {
|
|
// Residence Picker (only if needed)
|
|
if needsResidenceSelection, let residences = residences {
|
|
Section {
|
|
Picker(L10n.Tasks.property, selection: $selectedResidence) {
|
|
Text(L10n.Tasks.selectProperty).tag(nil as ResidenceResponse?)
|
|
ForEach(residences, id: \.id) { residence in
|
|
Text(residence.name).tag(residence as ResidenceResponse?)
|
|
}
|
|
}
|
|
|
|
if !residenceError.isEmpty {
|
|
Text(residenceError)
|
|
.font(.caption)
|
|
.foregroundColor(Color.appError)
|
|
}
|
|
} header: {
|
|
Text(L10n.Tasks.property)
|
|
} footer: {
|
|
Text(L10n.Tasks.required)
|
|
.font(.caption)
|
|
.foregroundColor(Color.appError)
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary)
|
|
}
|
|
|
|
// Browse Templates Button (only for new tasks)
|
|
if !isEditMode {
|
|
Section {
|
|
Button {
|
|
showingTemplatesBrowser = true
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "list.bullet.rectangle")
|
|
.font(.system(size: 18))
|
|
.foregroundColor(Color.appPrimary)
|
|
.frame(width: 28)
|
|
|
|
Text("Browse Task Templates")
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
Spacer()
|
|
|
|
Text("\(dataManager.taskTemplateCount) tasks")
|
|
.font(.caption)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Quick Start")
|
|
} footer: {
|
|
Text("Choose from common home maintenance tasks or create your own below")
|
|
.font(.caption)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary)
|
|
}
|
|
|
|
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 {
|
|
Text(titleError)
|
|
.font(.caption)
|
|
.foregroundColor(Color.appError)
|
|
}
|
|
|
|
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)
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary)
|
|
|
|
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)
|
|
} footer: {
|
|
Text(L10n.Tasks.required)
|
|
.font(.caption)
|
|
.foregroundColor(Color.appError)
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary)
|
|
|
|
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?)
|
|
}
|
|
}
|
|
|
|
if selectedFrequency?.name != "once" {
|
|
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: {
|
|
Text(L10n.Tasks.required)
|
|
.font(.caption)
|
|
.foregroundColor(Color.appError)
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary)
|
|
|
|
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)
|
|
} footer: {
|
|
Text(L10n.Tasks.required)
|
|
.font(.caption)
|
|
.foregroundColor(Color.appError)
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary)
|
|
|
|
Section(header: Text(L10n.Tasks.cost)) {
|
|
TextField(L10n.Tasks.estimatedCost, text: $estimatedCost)
|
|
.keyboardType(.decimalPad)
|
|
.focused($focusedField, equals: .estimatedCost)
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary)
|
|
|
|
if let errorMessage = viewModel.errorMessage {
|
|
Section {
|
|
Text(errorMessage)
|
|
.foregroundColor(Color.appError)
|
|
.font(.caption)
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary)
|
|
}
|
|
}
|
|
.disabled(isLoadingLookups)
|
|
.blur(radius: isLoadingLookups ? 3 : 0)
|
|
|
|
if isLoadingLookups {
|
|
VStack(spacing: 16) {
|
|
ProgressView()
|
|
.scaleEffect(1.5)
|
|
Text(L10n.Tasks.loading)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Color.appBackgroundPrimary.opacity(0.8))
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.background(Color.appBackgroundPrimary)
|
|
.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(L10n.Common.save) {
|
|
submitForm()
|
|
}
|
|
.disabled(!canSave || viewModel.isLoading || isLoadingLookups)
|
|
}
|
|
}
|
|
.onAppear {
|
|
// Track screen view for new tasks
|
|
if !isEditMode {
|
|
PostHogAnalytics.shared.screen(AnalyticsEvents.newTaskScreenShown)
|
|
}
|
|
// 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
|
|
}
|
|
|
|
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
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.dateFormat = "yyyy-MM-dd"
|
|
let dueDateString = dateFormatter.string(from: dueDate)
|
|
|
|
if isEditMode, let task = existingTask {
|
|
// UPDATE existing task
|
|
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)),
|
|
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
|
|
}
|
|
|
|
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)),
|
|
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
|
|
PostHogAnalytics.shared.capture(AnalyticsEvents.taskCreated, properties: ["residence_id": 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))
|
|
}
|