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,207 +32,33 @@ 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
SectionHeader(title: "Basic Information") contactInformationSection
businessDetailsSection
VStack(spacing: AppSpacing.sm) { addressSection
FormTextField( notesSection
title: "Name *", favoriteToggle
text: $name, errorMessage
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)
}
} }
.padding(AppSpacing.md) .padding(AppSpacing.md)
} }
@@ -234,22 +67,11 @@ struct ContractorFormSheet: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { cancelButton
dismiss()
}
.foregroundColor(AppColors.textSecondary)
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button(action: saveContractor) { saveButton
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) { .sheet(isPresented: $showingSpecialtyPicker) {
@@ -265,10 +87,231 @@ struct ContractorFormSheet: View {
} }
} }
private var canSave: Bool { // MARK: - Toolbar Items
!name.isEmpty && !phone.isEmpty
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() { private func loadContractorData() {
guard let contractor = contractor else { return } guard let contractor = contractor else { return }
@@ -291,11 +334,15 @@ 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 {
// Update existing contractor // Update existing 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(
// 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
// } // }
} //
// 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 // 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 return false
// case let error as ApiResultError: }
// print(" Failed to update preferences: \(error.message)") return false
// return false } catch {
// default: print("❌ Error updating notification preferences: \(error.localizedDescription)")
// return false return false
// } }
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 return nil
// default: }
// return nil return nil
// } } catch {
return nil 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,49 +134,68 @@ struct TaskFormView: View {
} }
} }
} }
.navigationTitle("Add Task") .disabled(isLoadingLookups)
.navigationBarTitleDisplayMode(.inline) .blur(radius: isLoadingLookups ? 3 : 0)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
isPresented = false
}
}
ToolbarItem(placement: .navigationBarTrailing) { if isLoadingLookups {
Button("Save") { VStack(spacing: 16) {
submitForm() ProgressView()
} .scaleEffect(1.5)
.disabled(viewModel.isLoading) Text("Loading...")
.foregroundColor(.secondary)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(uiColor: .systemBackground).opacity(0.8))
} }
.onAppear { }
loadLookups() .navigationTitle("Add Task")
} .navigationBarTitleDisplayMode(.inline)
.onChange(of: viewModel.taskCreated) { created in .toolbar {
if created { ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
isPresented = false 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() { private func loadLookups() async {
Task { // Load all lookups from DataCache
isLoadingLookups = true if let categories = DataCache.shared.taskCategories.value as? [TaskCategory] {
taskCategories = categories
// 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()
} }
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() { private func setDefaults() {