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 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<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 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<ContractorFormSheet.Field?>.Binding
var field: ContractorFormSheet.Field
var focused: FocusState<ContractorFormField?>.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)
}
}

View File

@@ -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<DeviceRegistrationResponse>:
// 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<DeviceRegistrationResponse> {
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<Notification>:
// 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<Notification> {
// 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<NotificationPreference>:
// 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<NotificationPreference> {
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<NotificationPreference>:
// 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<NotificationPreference> {
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
}
}

View File

@@ -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() {