diff --git a/iosApp/iosApp/Documents/DocumentFormView.swift b/iosApp/iosApp/Documents/DocumentFormView.swift index 5456e37..bfab175 100644 --- a/iosApp/iosApp/Documents/DocumentFormView.swift +++ b/iosApp/iosApp/Documents/DocumentFormView.swift @@ -96,6 +96,12 @@ struct DocumentFormView: View { selectedDocumentType == "warranty" } + var canSave: Bool { + !title.isEmpty && + (!needsResidenceSelection || selectedResidenceId != nil) && + (!isWarranty || (!itemName.isEmpty && !provider.isEmpty)) + } + var residencesArray: [(id: Int, name: String)] { guard let residences = residenceViewModel.myResidences?.residences else { return [] @@ -108,7 +114,7 @@ struct DocumentFormView: View { @ViewBuilder private var warrantySection: some View { if isWarranty { - Section("Warranty Details") { + Section { TextField("Item Name", text: $itemName) if !itemNameError.isEmpty { Text(itemNameError) @@ -127,6 +133,12 @@ struct DocumentFormView: View { } TextField("Provider Contact (optional)", text: $providerContact) + } header: { + Text("Warranty Details") + } footer: { + Text("Required for warranties: Item Name and Provider") + .font(.caption) + .foregroundColor(.red) } Section("Warranty Claims") { @@ -212,7 +224,7 @@ struct DocumentFormView: View { Button(isEditMode ? "Update" : "Save") { submitForm() } - .disabled(isProcessing) + .disabled(!canSave || isProcessing) } } .sheet(isPresented: $showCamera) { @@ -256,7 +268,7 @@ struct DocumentFormView: View { private var formContent: some View { // Residence Selection (Add mode only, if needed) if needsResidenceSelection { - Section(header: Text("Property")) { + Section { if residenceViewModel.isLoading { ProgressView() } else { @@ -273,6 +285,12 @@ struct DocumentFormView: View { .foregroundColor(.red) } } + } header: { + Text("Property") + } footer: { + Text("Required") + .font(.caption) + .foregroundColor(.red) } } @@ -298,7 +316,7 @@ struct DocumentFormView: View { } // Basic Information - Section("Basic Information") { + Section { TextField("Title", text: $title) if !titleError.isEmpty { Text(titleError) @@ -308,6 +326,12 @@ struct DocumentFormView: View { TextField("Description (optional)", text: $description, axis: .vertical) .lineLimit(3...6) + } header: { + Text("Basic Information") + } footer: { + Text("Required: Title") + .font(.caption) + .foregroundColor(.red) } // Warranty-specific fields diff --git a/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift b/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift new file mode 100644 index 0000000..7b7d2b3 --- /dev/null +++ b/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift @@ -0,0 +1,212 @@ +import Foundation + +/// Centralized accessibility identifiers for UI testing +/// These identifiers are used by XCUITests to locate and interact with UI elements +struct AccessibilityIdentifiers { + + // MARK: - Authentication + struct Authentication { + static let usernameField = "Login.UsernameField" + static let passwordField = "Login.PasswordField" + static let loginButton = "Login.LoginButton" + static let signUpButton = "Login.SignUpButton" + static let forgotPasswordButton = "Login.ForgotPasswordButton" + static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle" + + // Registration + static let registerUsernameField = "Register.UsernameField" + static let registerEmailField = "Register.EmailField" + static let registerPasswordField = "Register.PasswordField" + static let registerConfirmPasswordField = "Register.ConfirmPasswordField" + static let registerButton = "Register.RegisterButton" + static let registerCancelButton = "Register.CancelButton" + + // Verification + static let verificationCodeField = "Verification.CodeField" + static let verifyButton = "Verification.VerifyButton" + static let resendCodeButton = "Verification.ResendButton" + } + + // MARK: - Navigation + struct Navigation { + static let residencesTab = "TabBar.Residences" + static let tasksTab = "TabBar.Tasks" + static let contractorsTab = "TabBar.Contractors" + static let documentsTab = "TabBar.Documents" + static let profileTab = "TabBar.Profile" + static let backButton = "Navigation.BackButton" + } + + // MARK: - Residence + struct Residence { + // List + static let addButton = "Residence.AddButton" + static let residencesList = "Residence.List" + static let residenceCard = "Residence.Card" + static let emptyStateView = "Residence.EmptyState" + static let emptyStateButton = "Residence.EmptyState.AddButton" + + // Form + static let nameField = "ResidenceForm.NameField" + static let propertyTypePicker = "ResidenceForm.PropertyTypePicker" + static let streetAddressField = "ResidenceForm.StreetAddressField" + static let apartmentUnitField = "ResidenceForm.ApartmentUnitField" + static let cityField = "ResidenceForm.CityField" + static let stateProvinceField = "ResidenceForm.StateProvinceField" + static let postalCodeField = "ResidenceForm.PostalCodeField" + static let countryField = "ResidenceForm.CountryField" + static let bedroomsField = "ResidenceForm.BedroomsField" + static let bathroomsField = "ResidenceForm.BathroomsField" + static let squareFootageField = "ResidenceForm.SquareFootageField" + static let lotSizeField = "ResidenceForm.LotSizeField" + static let yearBuiltField = "ResidenceForm.YearBuiltField" + static let descriptionField = "ResidenceForm.DescriptionField" + static let isPrimaryToggle = "ResidenceForm.IsPrimaryToggle" + static let saveButton = "ResidenceForm.SaveButton" + static let formCancelButton = "ResidenceForm.CancelButton" + + // Detail + static let detailView = "ResidenceDetail.View" + static let editButton = "ResidenceDetail.EditButton" + static let deleteButton = "ResidenceDetail.DeleteButton" + static let shareButton = "ResidenceDetail.ShareButton" + static let manageUsersButton = "ResidenceDetail.ManageUsersButton" + static let tasksSection = "ResidenceDetail.TasksSection" + static let addTaskButton = "ResidenceDetail.AddTaskButton" + } + + // MARK: - Task + struct Task { + // List/Kanban + static let addButton = "Task.AddButton" + static let tasksList = "Task.List" + static let taskCard = "Task.Card" + static let emptyStateView = "Task.EmptyState" + static let kanbanView = "Task.KanbanView" + static let overdueColumn = "Task.Column.Overdue" + static let upcomingColumn = "Task.Column.Upcoming" + static let inProgressColumn = "Task.Column.InProgress" + static let completedColumn = "Task.Column.Completed" + + // Form + static let titleField = "TaskForm.TitleField" + static let descriptionField = "TaskForm.DescriptionField" + static let categoryPicker = "TaskForm.CategoryPicker" + static let frequencyPicker = "TaskForm.FrequencyPicker" + static let priorityPicker = "TaskForm.PriorityPicker" + static let statusPicker = "TaskForm.StatusPicker" + static let dueDatePicker = "TaskForm.DueDatePicker" + static let intervalDaysField = "TaskForm.IntervalDaysField" + static let estimatedCostField = "TaskForm.EstimatedCostField" + static let residencePicker = "TaskForm.ResidencePicker" + static let saveButton = "TaskForm.SaveButton" + static let formCancelButton = "TaskForm.CancelButton" + + // Detail + static let detailView = "TaskDetail.View" + static let editButton = "TaskDetail.EditButton" + static let deleteButton = "TaskDetail.DeleteButton" + static let markInProgressButton = "TaskDetail.MarkInProgressButton" + static let completeButton = "TaskDetail.CompleteButton" + static let detailCancelButton = "TaskDetail.CancelButton" + + // Completion + static let completionDatePicker = "TaskCompletion.CompletionDatePicker" + static let actualCostField = "TaskCompletion.ActualCostField" + static let ratingView = "TaskCompletion.RatingView" + static let notesField = "TaskCompletion.NotesField" + static let photosPicker = "TaskCompletion.PhotosPicker" + static let submitButton = "TaskCompletion.SubmitButton" + } + + // MARK: - Contractor + struct Contractor { + static let addButton = "Contractor.AddButton" + static let contractorsList = "Contractor.List" + static let contractorCard = "Contractor.Card" + static let emptyStateView = "Contractor.EmptyState" + + // Form + static let nameField = "ContractorForm.NameField" + static let companyField = "ContractorForm.CompanyField" + static let emailField = "ContractorForm.EmailField" + static let phoneField = "ContractorForm.PhoneField" + static let specialtyPicker = "ContractorForm.SpecialtyPicker" + static let ratingView = "ContractorForm.RatingView" + static let notesField = "ContractorForm.NotesField" + static let saveButton = "ContractorForm.SaveButton" + static let formCancelButton = "ContractorForm.CancelButton" + + // Detail + static let detailView = "ContractorDetail.View" + static let editButton = "ContractorDetail.EditButton" + static let deleteButton = "ContractorDetail.DeleteButton" + static let callButton = "ContractorDetail.CallButton" + static let emailButton = "ContractorDetail.EmailButton" + } + + // MARK: - Document + struct Document { + static let addButton = "Document.AddButton" + static let documentsList = "Document.List" + static let documentCard = "Document.Card" + static let emptyStateView = "Document.EmptyState" + + // Form + static let titleField = "DocumentForm.TitleField" + static let typePicker = "DocumentForm.TypePicker" + static let categoryPicker = "DocumentForm.CategoryPicker" + static let residencePicker = "DocumentForm.ResidencePicker" + static let filePicker = "DocumentForm.FilePicker" + static let notesField = "DocumentForm.NotesField" + static let expirationDatePicker = "DocumentForm.ExpirationDatePicker" + static let saveButton = "DocumentForm.SaveButton" + static let formCancelButton = "DocumentForm.CancelButton" + + // Detail + static let detailView = "DocumentDetail.View" + static let editButton = "DocumentDetail.EditButton" + static let deleteButton = "DocumentDetail.DeleteButton" + static let shareButton = "DocumentDetail.ShareButton" + static let downloadButton = "DocumentDetail.DownloadButton" + } + + // MARK: - Profile + struct Profile { + static let logoutButton = "Profile.LogoutButton" + static let editProfileButton = "Profile.EditProfileButton" + static let settingsButton = "Profile.SettingsButton" + static let notificationsToggle = "Profile.NotificationsToggle" + static let darkModeToggle = "Profile.DarkModeToggle" + static let aboutButton = "Profile.AboutButton" + static let helpButton = "Profile.HelpButton" + } + + // MARK: - Alerts & Modals + struct Alert { + static let confirmButton = "Alert.ConfirmButton" + static let cancelButton = "Alert.CancelButton" + static let deleteButton = "Alert.DeleteButton" + static let okButton = "Alert.OKButton" + } + + // MARK: - Common + struct Common { + static let loadingIndicator = "Common.LoadingIndicator" + static let errorView = "Common.ErrorView" + static let retryButton = "Common.RetryButton" + static let searchField = "Common.SearchField" + static let filterButton = "Common.FilterButton" + static let sortButton = "Common.SortButton" + static let refreshControl = "Common.RefreshControl" + } +} + +// MARK: - Helper Extension +extension String { + /// Convenience method to generate dynamic identifiers + /// Example: "Residence.Card.\(residenceId)" + func withId(_ id: Any) -> String { + return "\(self).\(id)" + } +} diff --git a/iosApp/iosApp/Login/LoginView.swift b/iosApp/iosApp/Login/LoginView.swift index cf21fc9..79227a6 100644 --- a/iosApp/iosApp/Login/LoginView.swift +++ b/iosApp/iosApp/Login/LoginView.swift @@ -4,14 +4,15 @@ struct LoginView: View { @StateObject private var viewModel = LoginViewModel() @FocusState private var focusedField: Field? @State private var showingRegister = false - @State private var showMainTab = false @State private var showVerification = false @State private var showPasswordReset = false @State private var isPasswordVisible = false @Binding var resetToken: String? + var onLoginSuccess: (() -> Void)? - init(resetToken: Binding = .constant(nil)) { + init(resetToken: Binding = .constant(nil), onLoginSuccess: (() -> Void)? = nil) { _resetToken = resetToken + self.onLoginSuccess = onLoginSuccess } enum Field { @@ -96,6 +97,7 @@ struct LoginView: View { .onChange(of: viewModel.username) { _, _ in viewModel.clearError() } + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.usernameField) } .padding(AppSpacing.md) .background(Color(.secondarySystemGroupedBackground)) @@ -129,6 +131,7 @@ struct LoginView: View { .onSubmit { viewModel.login() } + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.passwordField) } else { SecureField("Enter your password", text: $viewModel.password) .focused($focusedField, equals: .password) @@ -136,6 +139,7 @@ struct LoginView: View { .onSubmit { viewModel.login() } + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.passwordField) } } @@ -146,6 +150,7 @@ struct LoginView: View { .foregroundColor(Color(.tertiaryLabel)) .frame(width: 20) } + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.passwordVisibilityToggle) } .padding(AppSpacing.md) .background(Color(.secondarySystemGroupedBackground)) @@ -169,6 +174,7 @@ struct LoginView: View { } .font(.subheadline.weight(.medium)) .foregroundColor(.blue) + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.forgotPasswordButton) } // Error Message @@ -191,6 +197,7 @@ struct LoginView: View { loginButtonContent } .disabled(!isFormValid || viewModel.isLoading) + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.loginButton) // Sign Up Link HStack(spacing: AppSpacing.xs) { @@ -204,6 +211,7 @@ struct LoginView: View { .font(.body) .fontWeight(.semibold) .foregroundColor(.blue) + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.signUpButton) } } .padding(AppSpacing.xl) @@ -217,40 +225,29 @@ struct LoginView: View { } } .navigationBarHidden(true) - .onChange(of: viewModel.isAuthenticated) { _, isAuth in - if isAuth { - print("isAuthenticated changed to true, isVerified = \(viewModel.isVerified)") - if viewModel.isVerified { - showMainTab = true + .onAppear { + // Set up callback for login success + viewModel.onLoginSuccess = { [self] isVerified in + if isVerified { + // User is verified, call the success callback + self.onLoginSuccess?() } else { - showVerification = true + // User needs verification + self.showVerification = true } - } else { - print("isAuthenticated changed to false, dismissing main tab") - showMainTab = false - showVerification = false } } - .onChange(of: viewModel.isVerified) { _, isVerified in - print("isVerified changed to \(isVerified)") - if isVerified && viewModel.isAuthenticated { - showVerification = false - showMainTab = true - } - } - .fullScreenCover(isPresented: $showMainTab) { - MainTabView() - .environmentObject(viewModel) - } .fullScreenCover(isPresented: $showVerification) { VerifyEmailView( onVerifySuccess: { viewModel.isVerified = true + showVerification = false + // User is now verified, call the success callback + onLoginSuccess?() }, onLogout: { viewModel.logout() showVerification = false - showMainTab = false } ) } diff --git a/iosApp/iosApp/Login/LoginViewModel.swift b/iosApp/iosApp/Login/LoginViewModel.swift index 2024e6f..4d5273b 100644 --- a/iosApp/iosApp/Login/LoginViewModel.swift +++ b/iosApp/iosApp/Login/LoginViewModel.swift @@ -9,7 +9,6 @@ class LoginViewModel: ObservableObject { @Published var password: String = "" @Published var isLoading: Bool = false @Published var errorMessage: String? - @Published var isAuthenticated: Bool = false @Published var isVerified: Bool = false @Published var currentUser: User? @@ -18,13 +17,13 @@ class LoginViewModel: ObservableObject { private let tokenStorage: TokenStorage private var cancellables = Set() + // Callback for successful login + var onLoginSuccess: ((Bool) -> Void)? + // MARK: - Initialization init() { self.sharedViewModel = ComposeApp.AuthViewModel() self.tokenStorage = TokenStorage.shared - - // Check if user is already logged in - checkAuthenticationStatus() } // MARK: - Public Methods @@ -83,18 +82,14 @@ class LoginViewModel: ObservableObject { } } - // Update authentication state AFTER setting verified status - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.isAuthenticated = true - print("isAuthenticated set to true, isVerified is: \(self.isVerified)") - } + // Call login success callback + self.onLoginSuccess?(user.verified) } } break } else if let error = state as? ApiResultError { await MainActor.run { self.isLoading = false - self.isAuthenticated = false // Check for specific error codes and provide user-friendly messages if let code = error.code?.intValue { @@ -170,7 +165,6 @@ class LoginViewModel: ObservableObject { DataCache.shared.clearAll() // Reset state - isAuthenticated = false isVerified = false currentUser = nil username = "" @@ -187,7 +181,6 @@ class LoginViewModel: ObservableObject { // MARK: - Private Methods private func checkAuthenticationStatus() { guard tokenStorage.getToken() != nil else { - isAuthenticated = false isVerified = false return } @@ -202,7 +195,6 @@ class LoginViewModel: ObservableObject { if let user = success.data { self.currentUser = user self.isVerified = user.verified - self.isAuthenticated = true // Initialize lookups if verified if user.verified { @@ -220,7 +212,6 @@ class LoginViewModel: ObservableObject { await MainActor.run { // Token invalid or expired, clear it self.tokenStorage.clearToken() - self.isAuthenticated = false self.isVerified = false } sharedViewModel.resetCurrentUserState() diff --git a/iosApp/iosApp/MainTabView.swift b/iosApp/iosApp/MainTabView.swift index 4786677..80e04ce 100644 --- a/iosApp/iosApp/MainTabView.swift +++ b/iosApp/iosApp/MainTabView.swift @@ -1,9 +1,9 @@ import SwiftUI struct MainTabView: View { - @EnvironmentObject var loginViewModel: LoginViewModel @State private var selectedTab = 0 - + @StateObject private var authManager = AuthenticationManager.shared + var body: some View { TabView(selection: $selectedTab) { NavigationView { @@ -13,6 +13,7 @@ struct MainTabView: View { Label("Residences", systemImage: "house.fill") } .tag(0) + .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.residencesTab) NavigationView { AllTasksView() @@ -21,6 +22,7 @@ struct MainTabView: View { Label("Tasks", systemImage: "checkmark.circle.fill") } .tag(1) + .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.tasksTab) NavigationView { ContractorsListView() @@ -29,6 +31,7 @@ struct MainTabView: View { Label("Contractors", systemImage: "wrench.and.screwdriver.fill") } .tag(2) + .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab) NavigationView { DocumentsWarrantiesView(residenceId: nil) @@ -37,6 +40,7 @@ struct MainTabView: View { Label("Documents", systemImage: "doc.text.fill") } .tag(3) + .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab) NavigationView { ProfileTabView() @@ -45,6 +49,10 @@ struct MainTabView: View { Label("Profile", systemImage: "person.fill") } .tag(4) + .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.profileTab) + } + .onChange(of: authManager.isAuthenticated) { _ in + selectedTab = 0 } } } diff --git a/iosApp/iosApp/Profile/ProfileTabView.swift b/iosApp/iosApp/Profile/ProfileTabView.swift index 844f33f..7007184 100644 --- a/iosApp/iosApp/Profile/ProfileTabView.swift +++ b/iosApp/iosApp/Profile/ProfileTabView.swift @@ -1,8 +1,8 @@ import SwiftUI struct ProfileTabView: View { - @EnvironmentObject var loginViewModel: LoginViewModel @State private var showingProfileEdit = false + @State private var showingLogoutAlert = false var body: some View { List { @@ -44,11 +44,12 @@ struct ProfileTabView: View { Section { Button(action: { - loginViewModel.logout() + showingLogoutAlert = true }) { Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right") .foregroundColor(.red) } + .accessibilityIdentifier(AccessibilityIdentifiers.Profile.logoutButton) } Section { @@ -67,5 +68,13 @@ struct ProfileTabView: View { .sheet(isPresented: $showingProfileEdit) { ProfileView() } + .alert("Log Out", isPresented: $showingLogoutAlert) { + Button("Cancel", role: .cancel) { } + Button("Log Out", role: .destructive) { + AuthenticationManager.shared.logout() + } + } message: { + Text("Are you sure you want to log out?") + } } } diff --git a/iosApp/iosApp/Register/RegisterView.swift b/iosApp/iosApp/Register/RegisterView.swift index 3dc5c0f..4841995 100644 --- a/iosApp/iosApp/Register/RegisterView.swift +++ b/iosApp/iosApp/Register/RegisterView.swift @@ -42,6 +42,7 @@ struct RegisterView: View { .onSubmit { focusedField = .email } + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerUsernameField) TextField("Email", text: $viewModel.email) .textInputAutocapitalization(.never) @@ -52,6 +53,7 @@ struct RegisterView: View { .onSubmit { focusedField = .password } + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerEmailField) } header: { Text("Account Information") } @@ -63,6 +65,7 @@ struct RegisterView: View { .onSubmit { focusedField = .confirmPassword } + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerPasswordField) SecureField("Confirm Password", text: $viewModel.confirmPassword) .focused($focusedField, equals: .confirmPassword) @@ -70,6 +73,7 @@ struct RegisterView: View { .onSubmit { viewModel.register() } + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerConfirmPasswordField) } header: { Text("Security") } footer: { @@ -102,6 +106,7 @@ struct RegisterView: View { } } .disabled(viewModel.isLoading) + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerButton) } } .navigationTitle("Create Account") @@ -111,6 +116,7 @@ struct RegisterView: View { Button("Cancel") { dismiss() } + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerCancelButton) } } .fullScreenCover(isPresented: $viewModel.isRegistered) { diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index d980e60..d2bd0b0 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -5,7 +5,9 @@ struct ResidencesListView: View { @StateObject private var viewModel = ResidenceViewModel() @State private var showingAddResidence = false @State private var showingJoinResidence = false + @StateObject private var authManager = AuthenticationManager.shared + var body: some View { ZStack { Color(.systemGroupedBackground) @@ -83,6 +85,7 @@ struct ResidencesListView: View { .font(.system(size: 22, weight: .semibold)) .foregroundColor(.blue) } + .accessibilityIdentifier(AccessibilityIdentifiers.Residence.addButton) } } .sheet(isPresented: $showingAddResidence) { @@ -99,12 +102,26 @@ struct ResidencesListView: View { }) } .onAppear { - viewModel.loadMyResidences() + if authManager.isAuthenticated { + viewModel.loadMyResidences() + } } .handleErrors( error: viewModel.errorMessage, onRetry: { viewModel.loadMyResidences() } ) + .fullScreenCover(isPresented: $authManager.isAuthenticated.negated) { + LoginView(onLoginSuccess: { + authManager.isAuthenticated = true + viewModel.loadMyResidences() + }) + .interactiveDismissDisabled() + } + .onChange(of: authManager.isAuthenticated) { isAuth in + if !isAuth { + viewModel.myResidences = nil + } + } } } @@ -113,3 +130,12 @@ struct ResidencesListView: View { ResidencesListView() } } + +extension Binding where Value == Bool { + var negated: Binding { + Binding( + get: { !self.wrappedValue }, + set: { self.wrappedValue = !$0 } + ) + } +} diff --git a/iosApp/iosApp/Task/AddTaskView.swift b/iosApp/iosApp/Task/AddTaskView.swift index e8d07f1..3081ae5 100644 --- a/iosApp/iosApp/Task/AddTaskView.swift +++ b/iosApp/iosApp/Task/AddTaskView.swift @@ -1,12 +1,14 @@ import SwiftUI import ComposeApp +/// Wrapper view for adding a new task +/// This is now just a convenience wrapper around TaskFormView in "add" mode struct AddTaskView: View { let residenceId: Int32 @Binding var isPresented: Bool var body: some View { - TaskFormView(residenceId: residenceId, residences: nil, isPresented: $isPresented) + TaskFormView(residenceId: residenceId, residences: nil, existingTask: nil, isPresented: $isPresented) } } diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index 26178f4..43c99d1 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -142,6 +142,7 @@ struct AllTasksView: View { .controlSize(.large) .padding(.horizontal, 48) .disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true) + .accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton) if residenceViewModel.myResidences?.residences.isEmpty ?? true { Text("Add a property first from the Residences tab") @@ -225,6 +226,7 @@ struct AllTasksView: View { Image(systemName: "plus") } .disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true) + .accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton) } ToolbarItem(placement: .navigationBarTrailing) { diff --git a/iosApp/iosApp/Task/EditTaskView.swift b/iosApp/iosApp/Task/EditTaskView.swift index 9481449..7a0e23a 100644 --- a/iosApp/iosApp/Task/EditTaskView.swift +++ b/iosApp/iosApp/Task/EditTaskView.swift @@ -1,188 +1,13 @@ import SwiftUI import ComposeApp +/// Wrapper view for editing an existing task +/// This is now just a convenience wrapper around TaskFormView in "edit" mode struct EditTaskView: View { let task: TaskDetail @Binding var isPresented: Bool - @StateObject private var viewModel = TaskViewModel() - - @State private var title: String - @State private var description: String - @State private var selectedCategory: TaskCategory? - @State private var selectedFrequency: TaskFrequency? - @State private var selectedPriority: TaskPriority? - @State private var selectedStatus: TaskStatus? - @State private var dueDate: String - @State private var estimatedCost: String - - @State private var showAlert = false - @State private var alertMessage = "" - - // Lookups from DataCache - @State private var taskCategories: [TaskCategory] = [] - @State private var taskFrequencies: [TaskFrequency] = [] - @State private var taskPriorities: [TaskPriority] = [] - @State private var taskStatuses: [TaskStatus] = [] - - init(task: TaskDetail, isPresented: Binding) { - self.task = task - self._isPresented = isPresented - - // Initialize state from task - _title = State(initialValue: task.title) - _description = State(initialValue: task.description ?? "") - _selectedCategory = State(initialValue: task.category) - _selectedFrequency = State(initialValue: task.frequency) - _selectedPriority = State(initialValue: task.priority) - _selectedStatus = State(initialValue: task.status) - _dueDate = State(initialValue: task.dueDate ?? "") - _estimatedCost = State(initialValue: task.estimatedCost != nil ? String(task.estimatedCost!.doubleValue) : "") - } - var body: some View { - NavigationView { - Form { - Section(header: Text("Task Details")) { - TextField("Title", text: $title) - - TextField("Description", text: $description, axis: .vertical) - .lineLimit(3...6) - } - - Section(header: Text("Category")) { - Picker("Category", selection: $selectedCategory) { - ForEach(taskCategories, id: \.id) { category in - Text(category.name.capitalized).tag(category as TaskCategory?) - } - } - } - - Section(header: Text("Scheduling")) { - Picker("Frequency", selection: $selectedFrequency) { - ForEach(taskFrequencies, id: \.id) { frequency in - Text(frequency.name.capitalized).tag(frequency as TaskFrequency?) - } - } - - TextField("Due Date (YYYY-MM-DD)", text: $dueDate) - .keyboardType(.numbersAndPunctuation) - } - - Section(header: Text("Priority & Status")) { - Picker("Priority", selection: $selectedPriority) { - ForEach(taskPriorities, id: \.id) { priority in - Text(priority.name.capitalized).tag(priority as TaskPriority?) - } - } - - Picker("Status", selection: $selectedStatus) { - ForEach(taskStatuses, id: \.id) { status in - Text(status.name.capitalized).tag(status as TaskStatus?) - } - } - } - - Section(header: Text("Cost")) { - TextField("Estimated Cost", text: $estimatedCost) - .keyboardType(.decimalPad) - } - - if let errorMessage = viewModel.errorMessage { - Section { - Text(errorMessage) - .foregroundColor(.red) - .font(.caption) - } - } - } - .navigationTitle("Edit Task") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { - isPresented = false - } - } - - ToolbarItem(placement: .navigationBarTrailing) { - Button("Save") { - submitForm() - } - .disabled(!isFormValid()) - } - } - .alert("Success", isPresented: $showAlert) { - Button("OK") { - isPresented = false - } - } message: { - Text(alertMessage) - } - .onChange(of: viewModel.taskUpdated) { updated in - if updated { - alertMessage = "Task updated successfully" - showAlert = true - } - } - .onAppear { - loadLookups() - } - .handleErrors( - error: viewModel.errorMessage, - onRetry: { submitForm() } - ) - } - } - - private func loadLookups() { - Task { - 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] - } - } - } - - private func isFormValid() -> Bool { - return !title.isEmpty && - selectedCategory != nil && - selectedFrequency != nil && - selectedPriority != nil && - selectedStatus != nil && - !dueDate.isEmpty - } - - private func submitForm() { - guard isFormValid(), - let category = selectedCategory, - let frequency = selectedFrequency, - let priority = selectedPriority, - let status = selectedStatus else { - return - } - - let request = TaskCreateRequest( - residence: task.residence, - title: title, - description: description.isEmpty ? nil : description, - category: category.id, - frequency: frequency.id, - intervalDays: nil, - priority: priority.id, - status: KotlinInt(value: status.id), - dueDate: dueDate, - estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0), - archived: task.archived - ) - - viewModel.updateTask(id: task.id, request: request) { success in - if !success { - // Error is already set in viewModel.errorMessage - } - } + TaskFormView(residenceId: nil, residences: nil, existingTask: task, isPresented: $isPresented) } } - diff --git a/iosApp/iosApp/Task/TaskFormView.swift b/iosApp/iosApp/Task/TaskFormView.swift index 7f0bab7..d4b8d81 100644 --- a/iosApp/iosApp/Task/TaskFormView.swift +++ b/iosApp/iosApp/Task/TaskFormView.swift @@ -10,12 +10,26 @@ enum TaskFormField { struct TaskFormView: View { let residenceId: Int32? let residences: [Residence]? + let existingTask: TaskDetail? // nil for add mode, populated for edit mode @Binding var isPresented: Bool @StateObject private var viewModel = TaskViewModel() @FocusState private var focusedField: TaskFormField? + private var isEditMode: Bool { + existingTask != nil + } + private var needsResidenceSelection: Bool { - residenceId == nil + residenceId == nil && !isEditMode + } + + private var canSave: Bool { + !title.isEmpty && + (!needsResidenceSelection || selectedResidence != nil) && + selectedCategory != nil && + selectedFrequency != nil && + selectedPriority != nil && + selectedStatus != nil } // Lookups from DataCache @@ -27,15 +41,47 @@ struct TaskFormView: View { // Form fields @State private var selectedResidence: Residence? - @State private var title: String = "" - @State private var description: String = "" + @State private var title: String + @State private var description: String @State private var selectedCategory: TaskCategory? @State private var selectedFrequency: TaskFrequency? @State private var selectedPriority: TaskPriority? @State private var selectedStatus: TaskStatus? - @State private var dueDate: Date = Date() - @State private var intervalDays: String = "" - @State private var estimatedCost: String = "" + @State private var dueDate: Date + @State private var intervalDays: String + @State private var estimatedCost: String + + // Initialize form fields based on mode (add vs edit) + init(residenceId: Int32? = nil, residences: [Residence]? = nil, existingTask: TaskDetail? = nil, isPresented: Binding) { + self.residenceId = residenceId + self.residences = residences + self.existingTask = existingTask + self._isPresented = isPresented + + // Initialize fields from existing task or with defaults + if let task = existingTask { + _title = State(initialValue: task.title) + _description = State(initialValue: task.description ?? "") + _selectedCategory = State(initialValue: task.category) + _selectedFrequency = State(initialValue: task.frequency) + _selectedPriority = State(initialValue: task.priority) + _selectedStatus = State(initialValue: task.status) + + // Parse date from string + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + _dueDate = State(initialValue: formatter.date(from: task.dueDate ?? "") ?? Date()) + + _intervalDays = State(initialValue: task.intervalDays != nil ? String(task.intervalDays!.intValue) : "") + _estimatedCost = State(initialValue: task.estimatedCost != nil ? String(task.estimatedCost!.doubleValue) : "") + } else { + _title = State(initialValue: "") + _description = State(initialValue: "") + _dueDate = State(initialValue: Date()) + _intervalDays = State(initialValue: "") + _estimatedCost = State(initialValue: "") + } + } // Validation errors @State private var titleError: String = "" @@ -50,7 +96,7 @@ struct TaskFormView: View { Form { // Residence Picker (only if needed) if needsResidenceSelection, let residences = residences { - Section(header: Text("Property")) { + Section { Picker("Property", selection: $selectedResidence) { Text("Select Property").tag(nil as Residence?) ForEach(residences, id: \.id) { residence in @@ -63,10 +109,16 @@ struct TaskFormView: View { .font(.caption) .foregroundColor(.red) } + } header: { + Text("Property") + } footer: { + Text("Required") + .font(.caption) + .foregroundColor(.red) } } - Section(header: Text("Task Details")) { + Section { TextField("Title", text: $title) .focused($focusedField, equals: .title) @@ -79,18 +131,30 @@ struct TaskFormView: View { TextField("Description (optional)", text: $description, axis: .vertical) .lineLimit(3...6) .focused($focusedField, equals: .description) + } header: { + Text("Task Details") + } footer: { + Text("Required: Title") + .font(.caption) + .foregroundColor(.red) } - Section(header: Text("Category")) { + Section { Picker("Category", selection: $selectedCategory) { Text("Select Category").tag(nil as TaskCategory?) ForEach(taskCategories, id: \.id) { category in Text(category.name.capitalized).tag(category as TaskCategory?) } } + } header: { + Text("Category") + } footer: { + Text("Required") + .font(.caption) + .foregroundColor(.red) } - Section(header: Text("Scheduling")) { + Section { Picker("Frequency", selection: $selectedFrequency) { Text("Select Frequency").tag(nil as TaskFrequency?) ForEach(taskFrequencies, id: \.id) { frequency in @@ -105,9 +169,15 @@ struct TaskFormView: View { } DatePicker("Due Date", selection: $dueDate, displayedComponents: .date) + } header: { + Text("Scheduling") + } footer: { + Text("Required: Frequency") + .font(.caption) + .foregroundColor(.red) } - Section(header: Text("Priority & Status")) { + Section { Picker("Priority", selection: $selectedPriority) { Text("Select Priority").tag(nil as TaskPriority?) ForEach(taskPriorities, id: \.id) { priority in @@ -121,6 +191,12 @@ struct TaskFormView: View { Text(status.displayName).tag(status as TaskStatus?) } } + } header: { + Text("Priority & Status") + } footer: { + Text("Required: Both Priority and Status") + .font(.caption) + .foregroundColor(.red) } Section(header: Text("Cost")) { @@ -151,7 +227,7 @@ struct TaskFormView: View { .background(Color(uiColor: .systemBackground).opacity(0.8)) } } - .navigationTitle("Add Task") + .navigationTitle(isEditMode ? "Edit Task" : "Add Task") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -165,7 +241,7 @@ struct TaskFormView: View { Button("Save") { submitForm() } - .disabled(viewModel.isLoading || isLoadingLookups) + .disabled(!canSave || viewModel.isLoading || isLoadingLookups) } } .task { @@ -195,25 +271,34 @@ struct TaskFormView: View { } private func loadLookups() async { - // Load all lookups from DataCache - if let categories = DataCache.shared.taskCategories.value as? [TaskCategory] { - taskCategories = categories + // Wait a bit for lookups to be initialized (they load on app launch or login) + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + // Load lookups from DataCache + await MainActor.run { + if let categories = DataCache.shared.taskCategories.value as? [TaskCategory], + let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency], + let priorities = DataCache.shared.taskPriorities.value as? [TaskPriority], + let statuses = DataCache.shared.taskStatuses.value as? [TaskStatus] { + + self.taskCategories = categories + self.taskFrequencies = frequencies + self.taskPriorities = priorities + self.taskStatuses = statuses + + print("✅ TaskFormView: Loaded lookups - Categories: \(categories.count), Frequencies: \(frequencies.count), Priorities: \(priorities.count), Statuses: \(statuses.count)") + + setDefaults() + isLoadingLookups = false + } } - if let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency] { - taskFrequencies = frequencies + // If lookups not loaded, retry + if taskCategories.isEmpty { + print("⏳ TaskFormView: Lookups not ready, retrying...") + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + await loadLookups() } - - 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() { @@ -293,38 +378,62 @@ struct TaskFormView: View { return } - // Determine the actual residence ID to use - let actualResidenceId: Int32 - if let providedId = residenceId { - actualResidenceId = providedId - } else if let selected = selectedResidence { - actualResidenceId = Int32(selected.id) - } else { - return - } - // Format date as yyyy-MM-dd let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" let dueDateString = dateFormatter.string(from: dueDate) - let request = TaskCreateRequest( - residence: actualResidenceId, - title: title, - description: description.isEmpty ? nil : description, - category: Int32(category.id), - frequency: Int32(frequency.id), - intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt, - priority: Int32(priority.id), - status: selectedStatus.map { KotlinInt(value: $0.id) }, - dueDate: dueDateString, - estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0), - archived: false - ) + if isEditMode, let task = existingTask { + // UPDATE existing task + let request = TaskCreateRequest( + residence: task.residence, + title: title, + description: description.isEmpty ? nil : description, + category: Int32(category.id), + frequency: Int32(frequency.id), + intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt, + priority: Int32(priority.id), + status: KotlinInt(value: status.id) as? KotlinInt, + dueDate: dueDateString, + estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0), + archived: task.archived + ) - viewModel.createTask(request: request) { success in - if success { - // View will dismiss automatically via onChange + viewModel.updateTask(id: task.id, request: request) { success in + if success { + isPresented = false + } + } + } else { + // CREATE new task + // Determine the actual residence ID to use + let actualResidenceId: Int32 + if let providedId = residenceId { + actualResidenceId = providedId + } else if let selected = selectedResidence { + actualResidenceId = Int32(selected.id) + } else { + return + } + + let request = TaskCreateRequest( + residence: actualResidenceId, + title: title, + description: description.isEmpty ? nil : description, + category: Int32(category.id), + frequency: Int32(frequency.id), + intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt, + priority: Int32(priority.id), + status: selectedStatus.map { KotlinInt(value: $0.id) }, + dueDate: dueDateString, + estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0), + archived: false + ) + + viewModel.createTask(request: request) { success in + if success { + // View will dismiss automatically via onChange + } } } }