This commit is contained in:
Trey t
2025-11-05 15:15:59 -06:00
parent 5deac95818
commit 1d48a9bff1
13 changed files with 1360 additions and 871 deletions

View File

@@ -22,11 +22,6 @@ struct AddTaskView: View {
// Validation errors
@State private var titleError: String = ""
// Picker states
@State private var showCategoryPicker = false
@State private var showFrequencyPicker = false
@State private var showPriorityPicker = false
@State private var showStatusPicker = false
enum Field {
case title, description, intervalDays, estimatedCost
@@ -34,231 +29,110 @@ struct AddTaskView: View {
var body: some View {
NavigationView {
ZStack {
Color(.systemGroupedBackground)
.ignoresSafeArea()
if lookupsManager.isLoading {
VStack(spacing: 16) {
ProgressView()
Text("Loading lookup data...")
.foregroundColor(.secondary)
}
} else {
Form {
Section(header: Text("Task Details")) {
TextField("Title", text: $title)
.focused($focusedField, equals: .title)
if lookupsManager.isLoading {
VStack(spacing: 16) {
ProgressView()
Text("Loading lookup data...")
.foregroundColor(.secondary)
}
} else {
ScrollView {
VStack(spacing: 24) {
// Task Information Section
VStack(alignment: .leading, spacing: 16) {
Text("Task Information")
.font(.headline)
.foregroundColor(.blue)
// Title Field
VStack(alignment: .leading, spacing: 8) {
Text("Task Title *")
.font(.subheadline)
.foregroundColor(.secondary)
TextField("e.g., Clean gutters", text: $title)
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: .title)
if !titleError.isEmpty {
Text(titleError)
.font(.caption)
.foregroundColor(.red)
}
}
// Description Field
VStack(alignment: .leading, spacing: 8) {
Text("Description (Optional)")
.font(.subheadline)
.foregroundColor(.secondary)
TextEditor(text: $description)
.frame(height: 100)
.padding(8)
.background(Color(.systemBackground))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
}
// Category Picker
PickerField(
label: "Category *",
selectedItem: selectedCategory?.name ?? "Select Category",
showPicker: $showCategoryPicker
)
// Frequency Picker
PickerField(
label: "Frequency *",
selectedItem: selectedFrequency?.displayName ?? "Select Frequency",
showPicker: $showFrequencyPicker
)
// Interval Days (if applicable)
if selectedFrequency?.name != "once" {
VStack(alignment: .leading, spacing: 8) {
Text("Custom Interval (days, optional)")
.font(.subheadline)
.foregroundColor(.secondary)
TextField("Leave empty for default", text: $intervalDays)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
.focused($focusedField, equals: .intervalDays)
}
}
// Priority Picker
PickerField(
label: "Priority *",
selectedItem: selectedPriority?.displayName ?? "Select Priority",
showPicker: $showPriorityPicker
)
// Status Picker
PickerField(
label: "Status *",
selectedItem: selectedStatus?.displayName ?? "Select Status",
showPicker: $showStatusPicker
)
// Due Date Picker
VStack(alignment: .leading, spacing: 8) {
Text("Due Date *")
.font(.subheadline)
.foregroundColor(.secondary)
DatePicker("", selection: $dueDate, displayedComponents: .date)
.datePickerStyle(.compact)
.labelsHidden()
}
// Estimated Cost Field
VStack(alignment: .leading, spacing: 8) {
Text("Estimated Cost (Optional)")
.font(.subheadline)
.foregroundColor(.secondary)
TextField("e.g., 150.00", text: $estimatedCost)
.textFieldStyle(.roundedBorder)
.keyboardType(.decimalPad)
.focused($focusedField, equals: .estimatedCost)
}
}
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.font(.caption)
.foregroundColor(.red)
Spacer()
Button(action: viewModel.clearError) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
}
}
.padding()
.background(Color.red.opacity(0.1))
.cornerRadius(8)
}
// Submit Button
Button(action: submitForm) {
HStack {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Text("Create Task")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.isLoading ? Color.gray : Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
.disabled(viewModel.isLoading)
if !titleError.isEmpty {
Text(titleError)
.font(.caption)
.foregroundColor(.red)
}
TextField("Description (optional)", text: $description, axis: .vertical)
.lineLimit(3...6)
.focused($focusedField, equals: .description)
}
Section(header: Text("Category")) {
Picker("Category", selection: $selectedCategory) {
Text("Select Category").tag(nil as TaskCategory?)
ForEach(lookupsManager.taskCategories, id: \.id) { category in
Text(category.name.capitalized).tag(category as TaskCategory?)
}
}
}
Section(header: Text("Scheduling")) {
Picker("Frequency", selection: $selectedFrequency) {
Text("Select Frequency").tag(nil as TaskFrequency?)
ForEach(lookupsManager.taskFrequencies, id: \.id) { frequency in
Text(frequency.displayName).tag(frequency as TaskFrequency?)
}
}
if selectedFrequency?.name != "once" {
TextField("Custom Interval (days, optional)", text: $intervalDays)
.keyboardType(.numberPad)
.focused($focusedField, equals: .intervalDays)
}
DatePicker("Due Date", selection: $dueDate, displayedComponents: .date)
}
Section(header: Text("Priority & Status")) {
Picker("Priority", selection: $selectedPriority) {
Text("Select Priority").tag(nil as TaskPriority?)
ForEach(lookupsManager.taskPriorities, id: \.id) { priority in
Text(priority.displayName).tag(priority as TaskPriority?)
}
}
Picker("Status", selection: $selectedStatus) {
Text("Select Status").tag(nil as TaskStatus?)
ForEach(lookupsManager.taskStatuses, id: \.id) { status in
Text(status.displayName).tag(status as TaskStatus?)
}
}
}
Section(header: Text("Cost")) {
TextField("Estimated Cost (optional)", text: $estimatedCost)
.keyboardType(.decimalPad)
.focused($focusedField, equals: .estimatedCost)
}
if let errorMessage = viewModel.errorMessage {
Section {
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
}
.padding()
}
}
}
.navigationTitle("Add Task")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
.navigationTitle("Add Task")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
isPresented = false
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
submitForm()
}
.disabled(viewModel.isLoading)
}
}
.onAppear {
setDefaults()
}
.onChange(of: viewModel.taskCreated) { created in
if created {
isPresented = false
}
}
}
.sheet(isPresented: $showCategoryPicker) {
LookupPickerView(
title: "Select Category",
items: lookupsManager.taskCategories.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.name) },
selectedId: selectedCategory?.id,
isPresented: $showCategoryPicker,
onSelect: { id in
selectedCategory = lookupsManager.taskCategories.first { $0.id == id }
}
)
}
.sheet(isPresented: $showFrequencyPicker) {
LookupPickerView(
title: "Select Frequency",
items: lookupsManager.taskFrequencies.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.displayName) },
selectedId: selectedFrequency?.id,
isPresented: $showFrequencyPicker,
onSelect: { id in
selectedFrequency = lookupsManager.taskFrequencies.first { $0.id == id }
}
)
}
.sheet(isPresented: $showPriorityPicker) {
LookupPickerView(
title: "Select Priority",
items: lookupsManager.taskPriorities.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.displayName) },
selectedId: selectedPriority?.id,
isPresented: $showPriorityPicker,
onSelect: { id in
selectedPriority = lookupsManager.taskPriorities.first { $0.id == id }
}
)
}
.sheet(isPresented: $showStatusPicker) {
LookupPickerView(
title: "Select Status",
items: lookupsManager.taskStatuses.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.displayName) },
selectedId: selectedStatus?.id,
isPresented: $showStatusPicker,
onSelect: { id in
selectedStatus = lookupsManager.taskStatuses.first { $0.id == id }
}
)
}
.onAppear {
setDefaults()
}
.onChange(of: viewModel.taskCreated) { created in
if created {
isPresented = false
}
}
}
}
@@ -353,79 +227,6 @@ struct AddTaskView: View {
}
}
// MARK: - Supporting Views
struct PickerField: View {
let label: String
let selectedItem: String
@Binding var showPicker: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(label)
.font(.subheadline)
.foregroundColor(.secondary)
Button(action: {
showPicker = true
}) {
HStack {
Text(selectedItem)
.foregroundColor(selectedItem.contains("Select") ? .gray : .primary)
Spacer()
Image(systemName: "chevron.down")
.foregroundColor(.gray)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(8)
}
}
}
}
struct LookupItem: Identifiable {
let id: Int32
let name: String
let displayName: String
}
struct LookupPickerView: View {
let title: String
let items: [LookupItem]
let selectedId: Int32?
@Binding var isPresented: Bool
let onSelect: (Int32) -> Void
var body: some View {
NavigationView {
List(items) { item in
Button(action: {
onSelect(item.id)
isPresented = false
}) {
HStack {
Text(item.displayName)
.foregroundColor(.primary)
Spacer()
if selectedId == item.id {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
isPresented = false
}
}
}
}
}
}
#Preview {
AddTaskView(residenceId: 1, isPresented: .constant(true))

View File

@@ -0,0 +1,163 @@
import SwiftUI
import ComposeApp
struct EditTaskView: View {
let task: TaskDetail
@Binding var isPresented: Bool
@StateObject private var viewModel = TaskViewModel()
@StateObject private var lookupsManager = LookupsManager.shared
@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 selectedStatus: TaskStatus?
@State private var dueDate: String
@State private var estimatedCost: String
@State private var showAlert = false
@State private var alertMessage = ""
init(task: TaskDetail, isPresented: Binding<Bool>) {
self.task = task
self._isPresented = isPresented
// Initialize state from task
_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)
_selectedStatus = State(initialValue: task.status)
_dueDate = State(initialValue: task.dueDate)
_estimatedCost = State(initialValue: task.estimatedCost ?? "")
}
var body: some View {
NavigationView {
Form {
Section(header: Text("Task Details")) {
TextField("Title", text: $title)
TextField("Description", text: $description, axis: .vertical)
.lineLimit(3...6)
}
Section(header: Text("Category")) {
Picker("Category", selection: $selectedCategory) {
ForEach(lookupsManager.taskCategories, id: \.id) { category in
Text(category.name.capitalized).tag(category as TaskCategory?)
}
}
}
Section(header: Text("Scheduling")) {
Picker("Frequency", selection: $selectedFrequency) {
ForEach(lookupsManager.taskFrequencies, id: \.id) { frequency in
Text(frequency.name.capitalized).tag(frequency as TaskFrequency?)
}
}
TextField("Due Date (YYYY-MM-DD)", text: $dueDate)
.keyboardType(.numbersAndPunctuation)
}
Section(header: Text("Priority & Status")) {
Picker("Priority", selection: $selectedPriority) {
ForEach(lookupsManager.taskPriorities, id: \.id) { priority in
Text(priority.name.capitalized).tag(priority as TaskPriority?)
}
}
Picker("Status", selection: $selectedStatus) {
ForEach(lookupsManager.taskStatuses, id: \.id) { status in
Text(status.name.capitalized).tag(status as TaskStatus?)
}
}
}
Section(header: Text("Cost")) {
TextField("Estimated Cost", text: $estimatedCost)
.keyboardType(.decimalPad)
}
if let errorMessage = viewModel.errorMessage {
Section {
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
}
}
}
.navigationTitle("Edit Task")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
isPresented = false
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
submitForm()
}
.disabled(!isFormValid())
}
}
.alert("Success", isPresented: $showAlert) {
Button("OK") {
isPresented = false
}
} message: {
Text(alertMessage)
}
.onChange(of: viewModel.taskUpdated) { updated in
if updated {
alertMessage = "Task updated successfully"
showAlert = true
}
}
}
}
private func isFormValid() -> Bool {
return !title.isEmpty &&
selectedCategory != nil &&
selectedFrequency != nil &&
selectedPriority != nil &&
selectedStatus != nil &&
!dueDate.isEmpty
}
private func submitForm() {
guard isFormValid(),
let category = selectedCategory,
let frequency = selectedFrequency,
let priority = selectedPriority,
let status = selectedStatus else {
return
}
let request = TaskCreateRequest(
residence: task.residence,
title: title,
description: description.isEmpty ? nil : description,
category: category.id,
frequency: frequency.id,
intervalDays: nil,
priority: priority.id,
status: status.id,
dueDate: dueDate,
estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost
)
viewModel.updateTask(id: task.id, request: request) { success in
if !success {
// Error is already set in viewModel.errorMessage
}
}
}
}

View File

@@ -8,6 +8,9 @@ class TaskViewModel: ObservableObject {
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@Published var taskCreated: Bool = false
@Published var taskUpdated: Bool = false
@Published var taskCancelled: Bool = false
@Published var taskUncancelled: Bool = false
// MARK: - Private Properties
private let taskApi: TaskApi
@@ -49,12 +52,99 @@ class TaskViewModel: ObservableObject {
}
}
func updateTask(id: Int32, request: TaskCreateRequest, completion: @escaping (Bool) -> Void) {
guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated"
completion(false)
return
}
isLoading = true
errorMessage = nil
taskUpdated = false
taskApi.updateTask(token: token, id: id, request: request) { result, error in
if result is ApiResultSuccess<CustomTask> {
self.isLoading = false
self.taskUpdated = true
completion(true)
} else if let errorResult = result as? ApiResultError {
self.errorMessage = errorResult.message
self.isLoading = false
completion(false)
} else if let error = error {
self.errorMessage = error.localizedDescription
self.isLoading = false
completion(false)
}
}
}
func cancelTask(id: Int32, completion: @escaping (Bool) -> Void) {
guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated"
completion(false)
return
}
isLoading = true
errorMessage = nil
taskCancelled = false
taskApi.cancelTask(token: token, id: id) { result, error in
if result is ApiResultSuccess<TaskCancelResponse> {
self.isLoading = false
self.taskCancelled = true
completion(true)
} else if let errorResult = result as? ApiResultError {
self.errorMessage = errorResult.message
self.isLoading = false
completion(false)
} else if let error = error {
self.errorMessage = error.localizedDescription
self.isLoading = false
completion(false)
}
}
}
func uncancelTask(id: Int32, completion: @escaping (Bool) -> Void) {
guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated"
completion(false)
return
}
isLoading = true
errorMessage = nil
taskUncancelled = false
taskApi.uncancelTask(token: token, id: id) { result, error in
if result is ApiResultSuccess<TaskCancelResponse> {
self.isLoading = false
self.taskUncancelled = true
completion(true)
} else if let errorResult = result as? ApiResultError {
self.errorMessage = errorResult.message
self.isLoading = false
completion(false)
} else if let error = error {
self.errorMessage = error.localizedDescription
self.isLoading = false
completion(false)
}
}
}
func clearError() {
errorMessage = nil
}
func resetState() {
taskCreated = false
taskUpdated = false
taskCancelled = false
taskUncancelled = false
errorMessage = nil
}
}