Refactor iOS forms and integrate notification API with APILayer

- Refactored ContractorFormSheet to follow SwiftUI best practices
  - Moved Field enum outside struct and renamed to ContractorFormField
  - Extracted body into computed properties for better readability
  - Replaced deprecated NavigationView with NavigationStack
  - Fixed input field contrast in light mode by adding borders
  - Fixed force cast in loadContractorSpecialties

- Refactored TaskFormView to eliminate screen flickering
  - Moved Field enum outside struct and renamed to TaskFormField
  - Fixed conditional view structure that caused flicker on load
  - Used ZStack with overlay instead of if/else for loading state
  - Changed to .task modifier for proper async initialization
  - Made loadLookups properly async and fixed force casts
  - Replaced deprecated NavigationView with NavigationStack

- Integrated PushNotificationManager with APILayer
  - Updated registerDeviceWithBackend to use APILayer.shared.registerDevice()
  - Updated updateNotificationPreferences to use APILayer
  - Updated getNotificationPreferences to use APILayer
  - Added proper error handling with try-catch pattern

- Added notification operations to APILayer
  - Added NotificationApi instance
  - Implemented registerDevice, unregisterDevice
  - Implemented getNotificationPreferences, updateNotificationPreferences
  - Implemented getNotificationHistory, markNotificationAsRead
  - Implemented markAllNotificationsAsRead, getUnreadCount
  - All methods follow consistent pattern with auth token handling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-13 13:12:54 -06:00
parent 230eb013dd
commit 2b95c3b9c1
4 changed files with 458 additions and 349 deletions

View File

@@ -25,6 +25,7 @@ object APILayer {
private val contractorApi = ContractorApi() private val contractorApi = ContractorApi()
private val authApi = AuthApi() private val authApi = AuthApi()
private val lookupsApi = LookupsApi() private val lookupsApi = LookupsApi()
private val notificationApi = NotificationApi()
private val prefetchManager = DataPrefetchManager.getInstance() private val prefetchManager = DataPrefetchManager.getInstance()
// ==================== Lookups Operations ==================== // ==================== Lookups Operations ====================
@@ -852,4 +853,46 @@ object APILayer {
return result return result
} }
// ==================== Notification Operations ====================
suspend fun registerDevice(request: DeviceRegistrationRequest): ApiResult<DeviceRegistrationResponse> {
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
return notificationApi.registerDevice(token, request)
}
suspend fun unregisterDevice(registrationId: String): ApiResult<Unit> {
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
return notificationApi.unregisterDevice(token, registrationId)
}
suspend fun getNotificationPreferences(): ApiResult<NotificationPreference> {
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
return notificationApi.getNotificationPreferences(token)
}
suspend fun updateNotificationPreferences(request: UpdateNotificationPreferencesRequest): ApiResult<NotificationPreference> {
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
return notificationApi.updateNotificationPreferences(token, request)
}
suspend fun getNotificationHistory(): ApiResult<List<Notification>> {
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
return notificationApi.getNotificationHistory(token)
}
suspend fun markNotificationAsRead(notificationId: Int): ApiResult<Notification> {
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
return notificationApi.markNotificationAsRead(token, notificationId)
}
suspend fun markAllNotificationsAsRead(): ApiResult<Map<String, Int>> {
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
return notificationApi.markAllNotificationsAsRead(token)
}
suspend fun getUnreadCount(): ApiResult<UnreadCountResponse> {
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
return notificationApi.getUnreadCount(token)
}
} }

View File

@@ -1,6 +1,13 @@
import SwiftUI import SwiftUI
import ComposeApp 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 { struct ContractorFormSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = ContractorViewModel() @StateObject private var viewModel = ContractorViewModel()
@@ -25,31 +32,88 @@ struct ContractorFormSheet: View {
@State private var isFavorite = false @State private var isFavorite = false
@State private var showingSpecialtyPicker = false @State private var showingSpecialtyPicker = false
@FocusState private var focusedField: Field? @FocusState private var focusedField: ContractorFormField?
// Lookups from DataCache // Lookups from DataCache
@State private var contractorSpecialties: [ContractorSpecialty] = [] @State private var contractorSpecialties: [ContractorSpecialty] = []
var specialties: [String] { private var specialties: [String] {
contractorSpecialties.map { $0.name } contractorSpecialties.map { $0.name }
} }
enum Field: Hashable { private var canSave: Bool {
case name, company, phone, email, secondaryPhone, specialty, licenseNumber, website !name.isEmpty && !phone.isEmpty
case address, city, state, zipCode, notes
} }
var body: some View { var body: some View {
NavigationView { NavigationStack {
ZStack { ZStack {
AppColors.background.ignoresSafeArea() AppColors.background.ignoresSafeArea()
ScrollView { ScrollView {
VStack(spacing: AppSpacing.lg) { VStack(spacing: AppSpacing.lg) {
// Basic Information basicInformationSection
contactInformationSection
businessDetailsSection
addressSection
notesSection
favoriteToggle
errorMessage
}
.padding(AppSpacing.md)
}
}
.navigationTitle(contractor == nil ? "Add Contractor" : "Edit Contractor")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
cancelButton
}
ToolbarItem(placement: .navigationBarTrailing) {
saveButton
}
}
.sheet(isPresented: $showingSpecialtyPicker) {
SpecialtyPickerView(
selectedSpecialty: $specialty,
specialties: specialties
)
}
.onAppear {
loadContractorData()
loadContractorSpecialties()
}
}
}
// 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") SectionHeader(title: "Basic Information")
VStack(spacing: AppSpacing.sm) {
FormTextField( FormTextField(
title: "Name *", title: "Name *",
text: $name, text: $name,
@@ -66,11 +130,12 @@ struct ContractorFormSheet: View {
field: .company field: .company
) )
} }
}
// Contact Information private var contactInformationSection: some View {
VStack(spacing: AppSpacing.sm) {
SectionHeader(title: "Contact Information") SectionHeader(title: "Contact Information")
VStack(spacing: AppSpacing.sm) {
FormTextField( FormTextField(
title: "Phone *", title: "Phone *",
text: $phone, text: $phone,
@@ -98,31 +163,13 @@ struct ContractorFormSheet: View {
field: .secondaryPhone field: .secondaryPhone
) )
} }
}
// Business Details private var businessDetailsSection: some View {
VStack(spacing: AppSpacing.sm) {
SectionHeader(title: "Business Details") SectionHeader(title: "Business Details")
VStack(spacing: AppSpacing.sm) { specialtyPickerButton
// 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( FormTextField(
title: "License Number", title: "License Number",
@@ -141,11 +188,38 @@ struct ContractorFormSheet: View {
field: .website field: .website
) )
} }
}
// Address 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") SectionHeader(title: "Address")
VStack(spacing: AppSpacing.sm) {
FormTextField( FormTextField(
title: "Street Address", title: "Street Address",
text: $address, text: $address,
@@ -179,11 +253,12 @@ struct ContractorFormSheet: View {
field: .zipCode field: .zipCode
) )
} }
}
// Notes private var notesSection: some View {
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
SectionHeader(title: "Notes") SectionHeader(title: "Notes")
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
HStack { HStack {
Image(systemName: "note.text") Image(systemName: "note.text")
.foregroundColor(AppColors.textSecondary) .foregroundColor(AppColors.textSecondary)
@@ -197,12 +272,17 @@ struct ContractorFormSheet: View {
TextEditor(text: $notes) TextEditor(text: $notes)
.frame(height: 100) .frame(height: 100)
.padding(AppSpacing.sm) .padding(AppSpacing.sm)
.background(AppColors.surfaceSecondary) .background(AppColors.surface)
.cornerRadius(AppRadius.md) .cornerRadius(AppRadius.md)
.overlay(
RoundedRectangle(cornerRadius: AppRadius.md)
.stroke(AppColors.border, lineWidth: 1)
)
.focused($focusedField, equals: .notes) .focused($focusedField, equals: .notes)
} }
}
// Favorite Toggle private var favoriteToggle: some View {
Toggle(isOn: $isFavorite) { Toggle(isOn: $isFavorite) {
HStack { HStack {
Image(systemName: "star.fill") Image(systemName: "star.fill")
@@ -215,8 +295,10 @@ struct ContractorFormSheet: View {
.padding(AppSpacing.md) .padding(AppSpacing.md)
.background(AppColors.surface) .background(AppColors.surface)
.cornerRadius(AppRadius.md) .cornerRadius(AppRadius.md)
}
// Error Message @ViewBuilder
private var errorMessage: some View {
if let error = viewModel.errorMessage { if let error = viewModel.errorMessage {
Text(error) Text(error)
.font(.callout) .font(.callout)
@@ -227,47 +309,8 @@ struct ContractorFormSheet: View {
.cornerRadius(AppRadius.md) .cornerRadius(AppRadius.md)
} }
} }
.padding(AppSpacing.md)
}
}
.navigationTitle(contractor == nil ? "Add Contractor" : "Edit Contractor")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
.foregroundColor(AppColors.textSecondary)
}
ToolbarItem(placement: .navigationBarTrailing) { // MARK: - Data Loading
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)
}
}
.sheet(isPresented: $showingSpecialtyPicker) {
SpecialtyPickerView(
selectedSpecialty: $specialty,
specialties: specialties
)
}
.onAppear {
loadContractorData()
loadContractorSpecialties()
}
}
}
private var canSave: Bool {
!name.isEmpty && !phone.isEmpty
}
private func loadContractorData() { private func loadContractorData() {
guard let contractor = contractor else { return } guard let contractor = contractor else { return }
@@ -291,10 +334,14 @@ struct ContractorFormSheet: View {
private func loadContractorSpecialties() { private func loadContractorSpecialties() {
Task { Task {
await MainActor.run { 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() { private func saveContractor() {
if let contractor = contractor { if let contractor = contractor {
@@ -373,8 +420,8 @@ struct FormTextField: View {
@Binding var text: String @Binding var text: String
var icon: String? = nil var icon: String? = nil
var keyboardType: UIKeyboardType = .default var keyboardType: UIKeyboardType = .default
var focused: FocusState<ContractorFormSheet.Field?>.Binding var focused: FocusState<ContractorFormField?>.Binding
var field: ContractorFormSheet.Field var field: ContractorFormField
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: AppSpacing.xxs) { VStack(alignment: .leading, spacing: AppSpacing.xxs) {
@@ -398,8 +445,12 @@ struct FormTextField: View {
.keyboardType(keyboardType) .keyboardType(keyboardType)
.autocapitalization(keyboardType == .emailAddress ? .none : .words) .autocapitalization(keyboardType == .emailAddress ? .none : .words)
.padding(AppSpacing.md) .padding(AppSpacing.md)
.background(AppColors.surfaceSecondary) .background(AppColors.surface)
.cornerRadius(AppRadius.md) .cornerRadius(AppRadius.md)
.overlay(
RoundedRectangle(cornerRadius: AppRadius.md)
.stroke(AppColors.border, lineWidth: 1)
)
.focused(focused, equals: field) .focused(focused, equals: field)
} }
} }

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import UIKit
import UserNotifications import UserNotifications
import ComposeApp import ComposeApp
@@ -8,8 +9,6 @@ class PushNotificationManager: NSObject, ObservableObject {
@Published var deviceToken: String? @Published var deviceToken: String?
@Published var notificationPermissionGranted = false @Published var notificationPermissionGranted = false
// private let notificationApi = NotificationApi()
override init() { override init() {
super.init() super.init()
} }
@@ -19,26 +18,25 @@ class PushNotificationManager: NSObject, ObservableObject {
func requestNotificationPermission() async -> Bool { func requestNotificationPermission() async -> Bool {
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
// do { do {
// let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
// notificationPermissionGranted = granted notificationPermissionGranted = granted
//
// if granted { if granted {
// print(" Notification permission granted") print("✅ Notification permission granted")
// // Register for remote notifications on main thread // Register for remote notifications on main thread
// await MainActor.run { await MainActor.run {
// UIApplication.shared.registerForRemoteNotifications() UIApplication.shared.registerForRemoteNotifications()
// } }
// } else { } else {
// print(" Notification permission denied") print("❌ Notification permission denied")
// } }
//
// return granted return granted
// } catch { } catch {
// print(" Error requesting notification permission: \(error)") print("❌ Error requesting notification permission: \(error)")
// return false return false
// } }
return true
} }
// MARK: - Token Management // MARK: - Token Management
@@ -61,26 +59,29 @@ class PushNotificationManager: NSObject, ObservableObject {
// MARK: - Backend Registration // MARK: - Backend Registration
private func registerDeviceWithBackend(token: String) async { 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") print("⚠️ No auth token available, will register device after login")
return return
} }
// let request = DeviceRegistrationRequest( let request = DeviceRegistrationRequest(
// registrationId: token, registrationId: token,
// platform: "ios" platform: "ios"
// ) )
//
// let result = await notificationApi.registerDevice(token: authToken, request: request) do {
// let result = try await APILayer.shared.registerDevice(request: request)
// switch result {
// case let success as ApiResultSuccess<DeviceRegistrationResponse>: if let success = result as? ApiResultSuccess<DeviceRegistrationResponse> {
// print(" Device registered successfully: \(success.data)") print("✅ Device registered successfully: \(success.data)")
// case let error as ApiResultError: } else if let error = result as? ApiResultError {
// print(" Failed to register device: \(error.message)") print("❌ Failed to register device: \(error.message)")
// default: } else {
// print(" Unexpected result type from device registration") print("⚠️ Unexpected result type from device registration")
// } }
} catch {
print("❌ Error registering device: \(error.localizedDescription)")
}
} }
// MARK: - Handle Notifications // MARK: - Handle Notifications
@@ -92,10 +93,10 @@ class PushNotificationManager: NSObject, ObservableObject {
if let notificationId = userInfo["notification_id"] as? String { if let notificationId = userInfo["notification_id"] as? String {
print("Notification ID: \(notificationId)") print("Notification ID: \(notificationId)")
// Mark as read when user taps notification // // Mark as read when user taps notification
Task { // Task {
await markNotificationAsRead(notificationId: notificationId) // await markNotificationAsRead(notificationId: notificationId)
} // }
} }
if let type = userInfo["type"] as? String { if let type = userInfo["type"] as? String {
@@ -129,80 +130,79 @@ class PushNotificationManager: NSObject, ObservableObject {
} }
} }
private func markNotificationAsRead(notificationId: String) async { // private func markNotificationAsRead(notificationId: String) async {
guard let authToken = TokenStorage.shared.getToken(), // guard TokenStorage.shared.getToken() != nil,
let notificationIdInt = Int32(notificationId) else { // let notificationIdInt = Int32(notificationId) else {
return // return
} // }
//
// let result = await notificationApi.markNotificationAsRead( // do {
// token: authToken, // let result = try await APILayer.shared.markNotificationAsRead(notificationId: notificationIdInt)
// notificationId: notificationIdInt //
// ) // if result is ApiResultSuccess<Notification> {
// // print(" Notification marked as read")
// switch result { // } else if let error = result as? ApiResultError {
// case is ApiResultSuccess<Notification>: // print(" Failed to mark notification as read: \(error.message)")
// print(" Notification marked as read") // }
// case let error as ApiResultError: // } catch {
// print(" Failed to mark notification as read: \(error.message)") // print(" Error marking notification as read: \(error.localizedDescription)")
// default: // }
// break
// } // }
}
// MARK: - Notification Preferences // MARK: - Notification Preferences
func updateNotificationPreferences(_ preferences: UpdateNotificationPreferencesRequest) async -> Bool { func updateNotificationPreferences(_ preferences: UpdateNotificationPreferencesRequest) async -> Bool {
guard let authToken = TokenStorage.shared.getToken() else { guard TokenStorage.shared.getToken() != nil else {
print("⚠️ No auth token available") print("⚠️ No auth token available")
return false return false
} }
// let result = await notificationApi.updateNotificationPreferences( do {
// token: authToken, let result = try await APILayer.shared.updateNotificationPreferences(request: preferences)
// request: preferences
// ) if result is ApiResultSuccess<NotificationPreference> {
// print("✅ Notification preferences updated")
// switch result { return true
// case is ApiResultSuccess<NotificationPreference>: } else if let error = result as? ApiResultError {
// print(" Notification preferences updated") print("❌ Failed to update preferences: \(error.message)")
// return true
// case let error as ApiResultError:
// print(" Failed to update preferences: \(error.message)")
// return false
// default:
// return false
// }
return false return false
} }
return false
} catch {
print("❌ Error updating notification preferences: \(error.localizedDescription)")
return false
}
}
func getNotificationPreferences() async -> NotificationPreference? { func getNotificationPreferences() async -> NotificationPreference? {
guard let authToken = TokenStorage.shared.getToken() else { guard TokenStorage.shared.getToken() != nil else {
print("⚠️ No auth token available") print("⚠️ No auth token available")
return nil return nil
} }
// let result = await notificationApi.getNotificationPreferences(token: authToken) do {
// let result = try await APILayer.shared.getNotificationPreferences()
// switch result {
// case let success as ApiResultSuccess<NotificationPreference>: if let success = result as? ApiResultSuccess<NotificationPreference> {
// return success.data return success.data
// case let error as ApiResultError: } else if let error = result as? ApiResultError {
// print(" Failed to get preferences: \(error.message)") print("❌ Failed to get preferences: \(error.message)")
// return nil
// default:
// return nil
// }
return nil return nil
} }
return nil
} catch {
print("❌ Error getting notification preferences: \(error.localizedDescription)")
return nil
}
}
// MARK: - Badge Management // MARK: - Badge Management
// func clearBadge() { func clearBadge() {
// UIApplication.shared.applicationIconBadgeNumber = 0 UIApplication.shared.applicationIconBadgeNumber = 0
// } }
//
// func setBadge(count: Int) { func setBadge(count: Int) {
// UIApplication.shared.applicationIconBadgeNumber = count UIApplication.shared.applicationIconBadgeNumber = count
// } }
} }

View File

@@ -1,12 +1,18 @@
import SwiftUI import SwiftUI
import ComposeApp import ComposeApp
// MARK: - Field Focus Enum
enum TaskFormField {
case title, description, intervalDays, estimatedCost
}
// MARK: - Task Form View
struct TaskFormView: View { struct TaskFormView: View {
let residenceId: Int32? let residenceId: Int32?
let residences: [Residence]? let residences: [Residence]?
@Binding var isPresented: Bool @Binding var isPresented: Bool
@StateObject private var viewModel = TaskViewModel() @StateObject private var viewModel = TaskViewModel()
@FocusState private var focusedField: Field? @FocusState private var focusedField: TaskFormField?
private var needsResidenceSelection: Bool { private var needsResidenceSelection: Bool {
residenceId == nil residenceId == nil
@@ -17,7 +23,7 @@ struct TaskFormView: View {
@State private var taskFrequencies: [TaskFrequency] = [] @State private var taskFrequencies: [TaskFrequency] = []
@State private var taskPriorities: [TaskPriority] = [] @State private var taskPriorities: [TaskPriority] = []
@State private var taskStatuses: [TaskStatus] = [] @State private var taskStatuses: [TaskStatus] = []
@State private var isLoadingLookups: Bool = false @State private var isLoadingLookups: Bool = true
// Form fields // Form fields
@State private var selectedResidence: Residence? @State private var selectedResidence: Residence?
@@ -35,19 +41,9 @@ struct TaskFormView: View {
@State private var titleError: String = "" @State private var titleError: String = ""
@State private var residenceError: String = "" @State private var residenceError: String = ""
enum Field {
case title, description, intervalDays, estimatedCost
}
var body: some View { var body: some View {
NavigationView { NavigationStack {
if isLoadingLookups { ZStack {
VStack(spacing: 16) {
ProgressView()
Text("Loading...")
.foregroundColor(.secondary)
}
} else {
Form { Form {
// Residence Picker (only if needed) // Residence Picker (only if needed)
if needsResidenceSelection, let residences = residences { if needsResidenceSelection, let residences = residences {
@@ -138,6 +134,20 @@ struct TaskFormView: View {
} }
} }
} }
.disabled(isLoadingLookups)
.blur(radius: isLoadingLookups ? 3 : 0)
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))
}
}
.navigationTitle("Add Task") .navigationTitle("Add Task")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -145,17 +155,18 @@ struct TaskFormView: View {
Button("Cancel") { Button("Cancel") {
isPresented = false isPresented = false
} }
.disabled(isLoadingLookups)
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") { Button("Save") {
submitForm() submitForm()
} }
.disabled(viewModel.isLoading) .disabled(viewModel.isLoading || isLoadingLookups)
} }
} }
.onAppear { .task {
loadLookups() await loadLookups()
} }
.onChange(of: viewModel.taskCreated) { created in .onChange(of: viewModel.taskCreated) { created in
if created { if created {
@@ -164,23 +175,27 @@ struct TaskFormView: View {
} }
} }
} }
private func loadLookups() async {
// Load all lookups from DataCache
if let categories = DataCache.shared.taskCategories.value as? [TaskCategory] {
taskCategories = categories
} }
private func loadLookups() { if let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency] {
Task { taskFrequencies = frequencies
isLoadingLookups = true }
// Load all lookups from DataCache if let priorities = DataCache.shared.taskPriorities.value as? [TaskPriority] {
await MainActor.run { taskPriorities = priorities
self.taskCategories = DataCache.shared.taskCategories.value as! [TaskCategory] }
self.taskFrequencies = DataCache.shared.taskFrequencies.value as! [TaskFrequency]
self.taskPriorities = DataCache.shared.taskPriorities.value as! [TaskPriority] if let statuses = DataCache.shared.taskStatuses.value as? [TaskStatus] {
self.taskStatuses = DataCache.shared.taskStatuses.value as! [TaskStatus] taskStatuses = statuses
self.isLoadingLookups = false
} }
setDefaults() setDefaults()
} isLoadingLookups = false
} }
private func setDefaults() { private func setDefaults() {