diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/APILayer.kt index 27d95a1..1d97fc8 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/APILayer.kt @@ -25,6 +25,7 @@ object APILayer { private val contractorApi = ContractorApi() private val authApi = AuthApi() private val lookupsApi = LookupsApi() + private val notificationApi = NotificationApi() private val prefetchManager = DataPrefetchManager.getInstance() // ==================== Lookups Operations ==================== @@ -852,4 +853,46 @@ object APILayer { return result } + + // ==================== Notification Operations ==================== + + suspend fun registerDevice(request: DeviceRegistrationRequest): ApiResult { + val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + return notificationApi.registerDevice(token, request) + } + + suspend fun unregisterDevice(registrationId: String): ApiResult { + val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + return notificationApi.unregisterDevice(token, registrationId) + } + + suspend fun getNotificationPreferences(): ApiResult { + val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + return notificationApi.getNotificationPreferences(token) + } + + suspend fun updateNotificationPreferences(request: UpdateNotificationPreferencesRequest): ApiResult { + val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + return notificationApi.updateNotificationPreferences(token, request) + } + + suspend fun getNotificationHistory(): ApiResult> { + val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + return notificationApi.getNotificationHistory(token) + } + + suspend fun markNotificationAsRead(notificationId: Int): ApiResult { + val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + return notificationApi.markNotificationAsRead(token, notificationId) + } + + suspend fun markAllNotificationsAsRead(): ApiResult> { + val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + return notificationApi.markAllNotificationsAsRead(token) + } + + suspend fun getUnreadCount(): ApiResult { + val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + return notificationApi.getUnreadCount(token) + } } diff --git a/iosApp/iosApp/Contractor/ContractorFormSheet.swift b/iosApp/iosApp/Contractor/ContractorFormSheet.swift index 8b930ff..a1a1944 100644 --- a/iosApp/iosApp/Contractor/ContractorFormSheet.swift +++ b/iosApp/iosApp/Contractor/ContractorFormSheet.swift @@ -1,6 +1,13 @@ import SwiftUI import ComposeApp +// MARK: - Field Focus Enum +enum ContractorFormField: Hashable { + case name, company, phone, email, secondaryPhone, specialty, licenseNumber, website + case address, city, state, zipCode, notes +} + +// MARK: - Contractor Form Sheet struct ContractorFormSheet: View { @Environment(\.dismiss) private var dismiss @StateObject private var viewModel = ContractorViewModel() @@ -25,207 +32,33 @@ struct ContractorFormSheet: View { @State private var isFavorite = false @State private var showingSpecialtyPicker = false - @FocusState private var focusedField: Field? + @FocusState private var focusedField: ContractorFormField? // Lookups from DataCache @State private var contractorSpecialties: [ContractorSpecialty] = [] - var specialties: [String] { + private var specialties: [String] { contractorSpecialties.map { $0.name } } - enum Field: Hashable { - case name, company, phone, email, secondaryPhone, specialty, licenseNumber, website - case address, city, state, zipCode, notes + private var canSave: Bool { + !name.isEmpty && !phone.isEmpty } var body: some View { - NavigationView { + NavigationStack { ZStack { AppColors.background.ignoresSafeArea() ScrollView { VStack(spacing: AppSpacing.lg) { - // Basic Information - SectionHeader(title: "Basic Information") - - VStack(spacing: AppSpacing.sm) { - FormTextField( - title: "Name *", - text: $name, - icon: "person", - focused: $focusedField, - field: .name - ) - - FormTextField( - title: "Company", - text: $company, - icon: "building.2", - focused: $focusedField, - field: .company - ) - } - - // Contact Information - SectionHeader(title: "Contact Information") - - VStack(spacing: AppSpacing.sm) { - FormTextField( - title: "Phone *", - text: $phone, - icon: "phone", - keyboardType: .phonePad, - focused: $focusedField, - field: .phone - ) - - FormTextField( - title: "Email", - text: $email, - icon: "envelope", - keyboardType: .emailAddress, - focused: $focusedField, - field: .email - ) - - FormTextField( - title: "Secondary Phone", - text: $secondaryPhone, - icon: "phone", - keyboardType: .phonePad, - focused: $focusedField, - field: .secondaryPhone - ) - } - - // Business Details - SectionHeader(title: "Business Details") - - VStack(spacing: AppSpacing.sm) { - // Specialty Picker - Button(action: { showingSpecialtyPicker = true }) { - HStack { - Image(systemName: "wrench.and.screwdriver") - .foregroundColor(AppColors.textSecondary) - .frame(width: 20) - - Text(specialty.isEmpty ? "Specialty" : specialty) - .foregroundColor(specialty.isEmpty ? AppColors.textTertiary : AppColors.textPrimary) - - Spacer() - - Image(systemName: "chevron.down") - .font(.caption) - .foregroundColor(AppColors.textTertiary) - } - .padding(AppSpacing.md) - .background(AppColors.surfaceSecondary) - .cornerRadius(AppRadius.md) - } - - FormTextField( - title: "License Number", - text: $licenseNumber, - icon: "doc.badge", - focused: $focusedField, - field: .licenseNumber - ) - - FormTextField( - title: "Website", - text: $website, - icon: "globe", - keyboardType: .URL, - focused: $focusedField, - field: .website - ) - } - - // Address - SectionHeader(title: "Address") - - VStack(spacing: AppSpacing.sm) { - FormTextField( - title: "Street Address", - text: $address, - icon: "mappin", - focused: $focusedField, - field: .address - ) - - HStack(spacing: AppSpacing.sm) { - FormTextField( - title: "City", - text: $city, - focused: $focusedField, - field: .city - ) - - FormTextField( - title: "State", - text: $state, - focused: $focusedField, - field: .state - ) - .frame(maxWidth: 100) - } - - FormTextField( - title: "ZIP Code", - text: $zipCode, - keyboardType: .numberPad, - focused: $focusedField, - field: .zipCode - ) - } - - // Notes - SectionHeader(title: "Notes") - - VStack(alignment: .leading, spacing: AppSpacing.xxs) { - HStack { - Image(systemName: "note.text") - .foregroundColor(AppColors.textSecondary) - .frame(width: 20) - - Text("Private Notes") - .font(.footnote.weight(.medium)) - .foregroundColor(AppColors.textSecondary) - } - - TextEditor(text: $notes) - .frame(height: 100) - .padding(AppSpacing.sm) - .background(AppColors.surfaceSecondary) - .cornerRadius(AppRadius.md) - .focused($focusedField, equals: .notes) - } - - // Favorite Toggle - Toggle(isOn: $isFavorite) { - HStack { - Image(systemName: "star.fill") - .foregroundColor(isFavorite ? AppColors.warning : AppColors.textSecondary) - Text("Mark as Favorite") - .font(.body) - .foregroundColor(AppColors.textPrimary) - } - } - .padding(AppSpacing.md) - .background(AppColors.surface) - .cornerRadius(AppRadius.md) - - // Error Message - if let error = viewModel.errorMessage { - Text(error) - .font(.callout) - .foregroundColor(AppColors.error) - .padding(AppSpacing.sm) - .frame(maxWidth: .infinity) - .background(AppColors.error.opacity(0.1)) - .cornerRadius(AppRadius.md) - } + basicInformationSection + contactInformationSection + businessDetailsSection + addressSection + notesSection + favoriteToggle + errorMessage } .padding(AppSpacing.md) } @@ -234,22 +67,11 @@ struct ContractorFormSheet: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { - dismiss() - } - .foregroundColor(AppColors.textSecondary) + cancelButton } ToolbarItem(placement: .navigationBarTrailing) { - Button(action: saveContractor) { - if viewModel.isCreating || viewModel.isUpdating { - ProgressView() - } else { - Text(contractor == nil ? "Add" : "Save") - .foregroundColor(canSave ? AppColors.primary : AppColors.textTertiary) - } - } - .disabled(!canSave || viewModel.isCreating || viewModel.isUpdating) + saveButton } } .sheet(isPresented: $showingSpecialtyPicker) { @@ -265,10 +87,231 @@ struct ContractorFormSheet: View { } } - private var canSave: Bool { - !name.isEmpty && !phone.isEmpty + // MARK: - Toolbar Items + + private var cancelButton: some View { + Button("Cancel") { + dismiss() + } + .foregroundColor(AppColors.textSecondary) } + private var saveButton: some View { + Button(action: saveContractor) { + if viewModel.isCreating || viewModel.isUpdating { + ProgressView() + } else { + Text(contractor == nil ? "Add" : "Save") + .foregroundColor(canSave ? AppColors.primary : AppColors.textTertiary) + } + } + .disabled(!canSave || viewModel.isCreating || viewModel.isUpdating) + } + + // MARK: - Form Sections + + private var basicInformationSection: some View { + VStack(spacing: AppSpacing.sm) { + SectionHeader(title: "Basic Information") + + FormTextField( + title: "Name *", + text: $name, + icon: "person", + focused: $focusedField, + field: .name + ) + + FormTextField( + title: "Company", + text: $company, + icon: "building.2", + focused: $focusedField, + field: .company + ) + } + } + + private var contactInformationSection: some View { + VStack(spacing: AppSpacing.sm) { + SectionHeader(title: "Contact Information") + + FormTextField( + title: "Phone *", + text: $phone, + icon: "phone", + keyboardType: .phonePad, + focused: $focusedField, + field: .phone + ) + + FormTextField( + title: "Email", + text: $email, + icon: "envelope", + keyboardType: .emailAddress, + focused: $focusedField, + field: .email + ) + + FormTextField( + title: "Secondary Phone", + text: $secondaryPhone, + icon: "phone", + keyboardType: .phonePad, + focused: $focusedField, + field: .secondaryPhone + ) + } + } + + private var businessDetailsSection: some View { + VStack(spacing: AppSpacing.sm) { + SectionHeader(title: "Business Details") + + specialtyPickerButton + + FormTextField( + title: "License Number", + text: $licenseNumber, + icon: "doc.badge", + focused: $focusedField, + field: .licenseNumber + ) + + FormTextField( + title: "Website", + text: $website, + icon: "globe", + keyboardType: .URL, + focused: $focusedField, + field: .website + ) + } + } + + private var specialtyPickerButton: some View { + Button(action: { showingSpecialtyPicker = true }) { + HStack { + Image(systemName: "wrench.and.screwdriver") + .foregroundColor(AppColors.textSecondary) + .frame(width: 20) + + Text(specialty.isEmpty ? "Specialty" : specialty) + .foregroundColor(specialty.isEmpty ? AppColors.textTertiary : AppColors.textPrimary) + + Spacer() + + Image(systemName: "chevron.down") + .font(.caption) + .foregroundColor(AppColors.textTertiary) + } + .padding(AppSpacing.md) + .background(AppColors.surface) + .cornerRadius(AppRadius.md) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.md) + .stroke(AppColors.border, lineWidth: 1) + ) + } + } + + private var addressSection: some View { + VStack(spacing: AppSpacing.sm) { + SectionHeader(title: "Address") + + FormTextField( + title: "Street Address", + text: $address, + icon: "mappin", + focused: $focusedField, + field: .address + ) + + HStack(spacing: AppSpacing.sm) { + FormTextField( + title: "City", + text: $city, + focused: $focusedField, + field: .city + ) + + FormTextField( + title: "State", + text: $state, + focused: $focusedField, + field: .state + ) + .frame(maxWidth: 100) + } + + FormTextField( + title: "ZIP Code", + text: $zipCode, + keyboardType: .numberPad, + focused: $focusedField, + field: .zipCode + ) + } + } + + private var notesSection: some View { + VStack(alignment: .leading, spacing: AppSpacing.xxs) { + SectionHeader(title: "Notes") + + HStack { + Image(systemName: "note.text") + .foregroundColor(AppColors.textSecondary) + .frame(width: 20) + + Text("Private Notes") + .font(.footnote.weight(.medium)) + .foregroundColor(AppColors.textSecondary) + } + + TextEditor(text: $notes) + .frame(height: 100) + .padding(AppSpacing.sm) + .background(AppColors.surface) + .cornerRadius(AppRadius.md) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.md) + .stroke(AppColors.border, lineWidth: 1) + ) + .focused($focusedField, equals: .notes) + } + } + + private var favoriteToggle: some View { + Toggle(isOn: $isFavorite) { + HStack { + Image(systemName: "star.fill") + .foregroundColor(isFavorite ? AppColors.warning : AppColors.textSecondary) + Text("Mark as Favorite") + .font(.body) + .foregroundColor(AppColors.textPrimary) + } + } + .padding(AppSpacing.md) + .background(AppColors.surface) + .cornerRadius(AppRadius.md) + } + + @ViewBuilder + private var errorMessage: some View { + if let error = viewModel.errorMessage { + Text(error) + .font(.callout) + .foregroundColor(AppColors.error) + .padding(AppSpacing.sm) + .frame(maxWidth: .infinity) + .background(AppColors.error.opacity(0.1)) + .cornerRadius(AppRadius.md) + } + } + + // MARK: - Data Loading + private func loadContractorData() { guard let contractor = contractor else { return } @@ -291,11 +334,15 @@ struct ContractorFormSheet: View { private func loadContractorSpecialties() { Task { await MainActor.run { - self.contractorSpecialties = DataCache.shared.contractorSpecialties.value as! [ContractorSpecialty] + if let specialties = DataCache.shared.contractorSpecialties.value as? [ContractorSpecialty] { + self.contractorSpecialties = specialties + } } } } + // MARK: - Save Action + private func saveContractor() { if let contractor = contractor { // Update existing contractor @@ -373,8 +420,8 @@ struct FormTextField: View { @Binding var text: String var icon: String? = nil var keyboardType: UIKeyboardType = .default - var focused: FocusState.Binding - var field: ContractorFormSheet.Field + var focused: FocusState.Binding + var field: ContractorFormField var body: some View { VStack(alignment: .leading, spacing: AppSpacing.xxs) { @@ -398,8 +445,12 @@ struct FormTextField: View { .keyboardType(keyboardType) .autocapitalization(keyboardType == .emailAddress ? .none : .words) .padding(AppSpacing.md) - .background(AppColors.surfaceSecondary) + .background(AppColors.surface) .cornerRadius(AppRadius.md) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.md) + .stroke(AppColors.border, lineWidth: 1) + ) .focused(focused, equals: field) } } diff --git a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift index ddea13f..c26c31e 100644 --- a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift +++ b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift @@ -1,4 +1,5 @@ import Foundation +import UIKit import UserNotifications import ComposeApp @@ -8,8 +9,6 @@ class PushNotificationManager: NSObject, ObservableObject { @Published var deviceToken: String? @Published var notificationPermissionGranted = false -// private let notificationApi = NotificationApi() - override init() { super.init() } @@ -19,26 +18,25 @@ class PushNotificationManager: NSObject, ObservableObject { func requestNotificationPermission() async -> Bool { let center = UNUserNotificationCenter.current() -// do { -// let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) -// notificationPermissionGranted = granted -// -// if granted { -// print("✅ Notification permission granted") -// // Register for remote notifications on main thread -// await MainActor.run { -// UIApplication.shared.registerForRemoteNotifications() -// } -// } else { -// print("❌ Notification permission denied") -// } -// -// return granted -// } catch { -// print("❌ Error requesting notification permission: \(error)") -// return false -// } - return true + do { + let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) + notificationPermissionGranted = granted + + if granted { + print("✅ Notification permission granted") + // Register for remote notifications on main thread + await MainActor.run { + UIApplication.shared.registerForRemoteNotifications() + } + } else { + print("❌ Notification permission denied") + } + + return granted + } catch { + print("❌ Error requesting notification permission: \(error)") + return false + } } // MARK: - Token Management @@ -61,26 +59,29 @@ class PushNotificationManager: NSObject, ObservableObject { // MARK: - Backend Registration private func registerDeviceWithBackend(token: String) async { - guard let authToken = TokenStorage.shared.getToken() else { + guard TokenStorage.shared.getToken() != nil else { print("⚠️ No auth token available, will register device after login") return } -// let request = DeviceRegistrationRequest( -// registrationId: token, -// platform: "ios" -// ) -// -// let result = await notificationApi.registerDevice(token: authToken, request: request) -// -// switch result { -// case let success as ApiResultSuccess: -// print("✅ Device registered successfully: \(success.data)") -// case let error as ApiResultError: -// print("❌ Failed to register device: \(error.message)") -// default: -// print("⚠️ Unexpected result type from device registration") -// } + let request = DeviceRegistrationRequest( + registrationId: token, + platform: "ios" + ) + + do { + let result = try await APILayer.shared.registerDevice(request: request) + + if let success = result as? ApiResultSuccess { + print("✅ Device registered successfully: \(success.data)") + } else if let error = result as? ApiResultError { + print("❌ Failed to register device: \(error.message)") + } else { + print("⚠️ Unexpected result type from device registration") + } + } catch { + print("❌ Error registering device: \(error.localizedDescription)") + } } // MARK: - Handle Notifications @@ -92,10 +93,10 @@ class PushNotificationManager: NSObject, ObservableObject { if let notificationId = userInfo["notification_id"] as? String { print("Notification ID: \(notificationId)") - // Mark as read when user taps notification - Task { - await markNotificationAsRead(notificationId: notificationId) - } +// // Mark as read when user taps notification +// Task { +// await markNotificationAsRead(notificationId: notificationId) +// } } if let type = userInfo["type"] as? String { @@ -129,80 +130,79 @@ class PushNotificationManager: NSObject, ObservableObject { } } - private func markNotificationAsRead(notificationId: String) async { - guard let authToken = TokenStorage.shared.getToken(), - let notificationIdInt = Int32(notificationId) else { - return - } - -// let result = await notificationApi.markNotificationAsRead( -// token: authToken, -// notificationId: notificationIdInt -// ) -// -// switch result { -// case is ApiResultSuccess: -// print("✅ Notification marked as read") -// case let error as ApiResultError: -// print("❌ Failed to mark notification as read: \(error.message)") -// default: -// break +// private func markNotificationAsRead(notificationId: String) async { +// guard TokenStorage.shared.getToken() != nil, +// let notificationIdInt = Int32(notificationId) else { +// return // } - } +// +// do { +// let result = try await APILayer.shared.markNotificationAsRead(notificationId: notificationIdInt) +// +// if result is ApiResultSuccess { +// print("✅ Notification marked as read") +// } else if let error = result as? ApiResultError { +// print("❌ Failed to mark notification as read: \(error.message)") +// } +// } catch { +// print("❌ Error marking notification as read: \(error.localizedDescription)") +// } +// } // MARK: - Notification Preferences func updateNotificationPreferences(_ preferences: UpdateNotificationPreferencesRequest) async -> Bool { - guard let authToken = TokenStorage.shared.getToken() else { + guard TokenStorage.shared.getToken() != nil else { print("⚠️ No auth token available") return false } -// let result = await notificationApi.updateNotificationPreferences( -// token: authToken, -// request: preferences -// ) -// -// switch result { -// case is ApiResultSuccess: -// print("✅ Notification preferences updated") -// return true -// case let error as ApiResultError: -// print("❌ Failed to update preferences: \(error.message)") -// return false -// default: -// return false -// } - return false + do { + let result = try await APILayer.shared.updateNotificationPreferences(request: preferences) + + if result is ApiResultSuccess { + print("✅ Notification preferences updated") + return true + } else if let error = result as? ApiResultError { + print("❌ Failed to update preferences: \(error.message)") + return false + } + return false + } catch { + print("❌ Error updating notification preferences: \(error.localizedDescription)") + return false + } } func getNotificationPreferences() async -> NotificationPreference? { - guard let authToken = TokenStorage.shared.getToken() else { + guard TokenStorage.shared.getToken() != nil else { print("⚠️ No auth token available") return nil } -// let result = await notificationApi.getNotificationPreferences(token: authToken) -// -// switch result { -// case let success as ApiResultSuccess: -// return success.data -// case let error as ApiResultError: -// print("❌ Failed to get preferences: \(error.message)") -// return nil -// default: -// return nil -// } - return nil + do { + let result = try await APILayer.shared.getNotificationPreferences() + + if let success = result as? ApiResultSuccess { + return success.data + } else if let error = result as? ApiResultError { + print("❌ Failed to get preferences: \(error.message)") + return nil + } + return nil + } catch { + print("❌ Error getting notification preferences: \(error.localizedDescription)") + return nil + } } // MARK: - Badge Management -// func clearBadge() { -// UIApplication.shared.applicationIconBadgeNumber = 0 -// } -// -// func setBadge(count: Int) { -// UIApplication.shared.applicationIconBadgeNumber = count -// } + func clearBadge() { + UIApplication.shared.applicationIconBadgeNumber = 0 + } + + func setBadge(count: Int) { + UIApplication.shared.applicationIconBadgeNumber = count + } } diff --git a/iosApp/iosApp/Task/TaskFormView.swift b/iosApp/iosApp/Task/TaskFormView.swift index e167d4d..bc867dd 100644 --- a/iosApp/iosApp/Task/TaskFormView.swift +++ b/iosApp/iosApp/Task/TaskFormView.swift @@ -1,12 +1,18 @@ 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: [Residence]? @Binding var isPresented: Bool @StateObject private var viewModel = TaskViewModel() - @FocusState private var focusedField: Field? + @FocusState private var focusedField: TaskFormField? private var needsResidenceSelection: Bool { residenceId == nil @@ -17,7 +23,7 @@ struct TaskFormView: View { @State private var taskFrequencies: [TaskFrequency] = [] @State private var taskPriorities: [TaskPriority] = [] @State private var taskStatuses: [TaskStatus] = [] - @State private var isLoadingLookups: Bool = false + @State private var isLoadingLookups: Bool = true // Form fields @State private var selectedResidence: Residence? @@ -35,19 +41,9 @@ struct TaskFormView: View { @State private var titleError: String = "" @State private var residenceError: String = "" - enum Field { - case title, description, intervalDays, estimatedCost - } - var body: some View { - NavigationView { - if isLoadingLookups { - VStack(spacing: 16) { - ProgressView() - Text("Loading...") - .foregroundColor(.secondary) - } - } else { + NavigationStack { + ZStack { Form { // Residence Picker (only if needed) if needsResidenceSelection, let residences = residences { @@ -138,49 +134,68 @@ struct TaskFormView: View { } } } - .navigationTitle("Add Task") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { - isPresented = false - } - } + .disabled(isLoadingLookups) + .blur(radius: isLoadingLookups ? 3 : 0) - ToolbarItem(placement: .navigationBarTrailing) { - Button("Save") { - submitForm() - } - .disabled(viewModel.isLoading) + if isLoadingLookups { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.5) + Text("Loading...") + .foregroundColor(.secondary) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .systemBackground).opacity(0.8)) } - .onAppear { - loadLookups() - } - .onChange(of: viewModel.taskCreated) { created in - if created { + } + .navigationTitle("Add Task") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { isPresented = false } + .disabled(isLoadingLookups) + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + submitForm() + } + .disabled(viewModel.isLoading || isLoadingLookups) + } + } + .task { + await loadLookups() + } + .onChange(of: viewModel.taskCreated) { created in + if created { + isPresented = false } } } } - private func loadLookups() { - Task { - isLoadingLookups = true - - // Load all lookups from DataCache - await MainActor.run { - self.taskCategories = DataCache.shared.taskCategories.value as! [TaskCategory] - self.taskFrequencies = DataCache.shared.taskFrequencies.value as! [TaskFrequency] - self.taskPriorities = DataCache.shared.taskPriorities.value as! [TaskPriority] - self.taskStatuses = DataCache.shared.taskStatuses.value as! [TaskStatus] - self.isLoadingLookups = false - } - - setDefaults() + private func loadLookups() async { + // Load all lookups from DataCache + if let categories = DataCache.shared.taskCategories.value as? [TaskCategory] { + taskCategories = categories } + + if let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency] { + taskFrequencies = frequencies + } + + if let priorities = DataCache.shared.taskPriorities.value as? [TaskPriority] { + taskPriorities = priorities + } + + if let statuses = DataCache.shared.taskStatuses.value as? [TaskStatus] { + taskStatuses = statuses + } + + setDefaults() + isLoadingLookups = false } private func setDefaults() {