diff --git a/iosApp/iosApp/Contractor/ContractorViewModel.swift b/iosApp/iosApp/Contractor/ContractorViewModel.swift index f3d810b..7ded601 100644 --- a/iosApp/iosApp/Contractor/ContractorViewModel.swift +++ b/iosApp/iosApp/Contractor/ContractorViewModel.swift @@ -19,9 +19,10 @@ class ContractorViewModel: ObservableObject { // MARK: - Private Properties private var cancellables = Set() - /// Guards against redundant detail reloads immediately after a mutation that already - /// set selectedContractor from its response. - private var suppressNextDetailReload = false + /// Timestamp of the last mutation that already set selectedContractor from its response. + /// Used to suppress redundant detail reloads within 1 second of a mutation. + /// Unlike a boolean flag, this naturally expires and can never get stuck. + private var lastMutationTime: Date? // MARK: - Initialization @@ -39,8 +40,10 @@ class ContractorViewModel: ObservableObject { if let self = self, let currentId = self.selectedContractor?.id, contractors.contains(where: { $0.id == currentId }) { - if self.suppressNextDetailReload { - self.suppressNextDetailReload = false + // Skip reload if a mutation just updated selectedContractor within the last second + if let mutationTime = self.lastMutationTime, + Date().timeIntervalSince(mutationTime) < 1.0 { + // Mutation was recent, no need to reload } else { self.reloadSelectedContractorQuietly(id: currentId) } @@ -120,7 +123,7 @@ class ContractorViewModel: ObservableObject { self.successMessage = "Contractor added successfully" self.isCreating = false // Update selectedContractor with the newly created contractor - self.suppressNextDetailReload = true + self.lastMutationTime = Date() self.selectedContractor = success.data completion(true) } else if let error = ApiResultBridge.error(from: result) { @@ -152,7 +155,7 @@ class ContractorViewModel: ObservableObject { self.successMessage = "Contractor updated successfully" self.isUpdating = false // Update selectedContractor immediately so detail views stay fresh - self.suppressNextDetailReload = true + self.lastMutationTime = Date() self.selectedContractor = success.data completion(true) } else if let error = ApiResultBridge.error(from: result) { @@ -209,7 +212,7 @@ class ContractorViewModel: ObservableObject { if let success = result as? ApiResultSuccess { // Update selectedContractor immediately so detail views stay fresh - self.suppressNextDetailReload = true + self.lastMutationTime = Date() self.selectedContractor = success.data completion(true) } else if let error = ApiResultBridge.error(from: result) { diff --git a/iosApp/iosApp/Data/DataManagerObservable.swift b/iosApp/iosApp/Data/DataManagerObservable.swift index 7a4af66..d7089ac 100644 --- a/iosApp/iosApp/Data/DataManagerObservable.swift +++ b/iosApp/iosApp/Data/DataManagerObservable.swift @@ -389,6 +389,8 @@ class DataManagerObservable: ObservableObject { } var result: [Int32: V] = [:] + var failedKeys = 0 + let totalKeys = nsDict.allKeys.count for key in nsDict.allKeys { guard let value = nsDict[key], let typedValue = value as? V else { continue } @@ -398,9 +400,16 @@ class DataManagerObservable: ObservableObject { result[kotlinKey.int32Value] = typedValue } else if let nsNumberKey = key as? NSNumber { result[nsNumberKey.int32Value] = typedValue + } else { + failedKeys += 1 + print("DataManagerObservable: convertIntMap failed to convert key of type \(type(of: key)): \(key)") } } + if failedKeys > 0 { + print("DataManagerObservable: Warning: \(failedKeys) of \(totalKeys) keys failed to convert in convertIntMap") + } + return result } @@ -416,6 +425,8 @@ class DataManagerObservable: ObservableObject { } var result: [Int32: [V]] = [:] + var failedKeys = 0 + let totalKeys = nsDict.allKeys.count for key in nsDict.allKeys { guard let value = nsDict[key] else { continue } @@ -435,9 +446,16 @@ class DataManagerObservable: ObservableObject { result[kotlinKey.int32Value] = typedValue } else if let nsNumberKey = key as? NSNumber { result[nsNumberKey.int32Value] = typedValue + } else { + failedKeys += 1 + print("DataManagerObservable: convertIntArrayMap failed to convert key of type \(type(of: key)): \(key)") } } + if failedKeys > 0 { + print("DataManagerObservable: Warning: \(failedKeys) of \(totalKeys) keys failed to convert in convertIntArrayMap") + } + return result } diff --git a/iosApp/iosApp/Documents/DocumentViewModel.swift b/iosApp/iosApp/Documents/DocumentViewModel.swift index 1acca5f..e0a81e5 100644 --- a/iosApp/iosApp/Documents/DocumentViewModel.swift +++ b/iosApp/iosApp/Documents/DocumentViewModel.swift @@ -130,14 +130,23 @@ class DocumentViewModel: ObservableObject { if let success = result as? ApiResultSuccess { if !images.isEmpty { - guard let documentId = success.data?.id?.int32Value else { - self.errorMessage = "Document created, but image upload could not start" + guard let document = success.data, + let documentId = document.id?.int32Value, + document.title != nil else { + let missingFields = [ + success.data == nil ? "data" : nil, + success.data?.id == nil ? "id" : nil, + success.data?.title == nil ? "title" : nil + ].compactMap { $0 }.joined(separator: ", ") + print("DocumentViewModel: Document creation returned incomplete data (missing: \(missingFields)), skipping image upload") + self.errorMessage = "Document created with incomplete data — images were not uploaded" self.isLoading = false completion(false, self.errorMessage) return } if let uploadError = await self.uploadImages(documentId: documentId, images: images) { + print("DocumentViewModel: Image upload failed for document \(documentId): \(uploadError)") self.errorMessage = uploadError self.isLoading = false completion(false, self.errorMessage) diff --git a/iosApp/iosApp/Onboarding/OnboardingState.swift b/iosApp/iosApp/Onboarding/OnboardingState.swift index bd35d99..6939f6f 100644 --- a/iosApp/iosApp/Onboarding/OnboardingState.swift +++ b/iosApp/iosApp/Onboarding/OnboardingState.swift @@ -124,7 +124,12 @@ class OnboardingState: ObservableObject { /// Complete the onboarding flow. /// Setting `hasCompletedOnboarding` also syncs with Kotlin DataManager via `didSet`. + /// Guards against completing without authentication to prevent broken state. func completeOnboarding() { + guard AuthenticationManager.shared.isAuthenticated else { + // Don't complete onboarding without auth + return + } hasCompletedOnboarding = true isOnboardingActive = false pendingResidenceName = "" diff --git a/iosApp/iosApp/Residence/ResidenceViewModel.swift b/iosApp/iosApp/Residence/ResidenceViewModel.swift index 76f0ae1..13db645 100644 --- a/iosApp/iosApp/Residence/ResidenceViewModel.swift +++ b/iosApp/iosApp/Residence/ResidenceViewModel.swift @@ -96,6 +96,13 @@ class ResidenceViewModel: ObservableObject { /// Load my residences - checks cache first, then fetches if needed func loadMyResidences(forceRefresh: Bool = false) { + // Ensure lookups are initialized (may not be during onboarding) + if !DataManagerObservable.shared.lookupsInitialized { + Task { + _ = try? await APILayer.shared.initializeLookups() + } + } + if UITestRuntime.shouldMockAuth { if Self.uiTestMockResidences.isEmpty || forceRefresh { if Self.uiTestMockResidences.isEmpty { diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index 77209aa..9189d0b 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -91,13 +91,13 @@ struct ResidencesListView: View { AddResidenceView( isPresented: $showingAddResidence, onResidenceCreated: { - viewModel.loadMyResidences(forceRefresh: true) + refreshWithTimeout() } ) } .sheet(isPresented: $showingJoinResidence) { JoinResidenceView(onJoined: { - viewModel.loadMyResidences(forceRefresh: true) + refreshWithTimeout() }) } .sheet(isPresented: $showingUpgradePrompt) { @@ -142,6 +142,17 @@ struct ResidencesListView: View { } } + /// Refresh residences with a 10-second timeout to prevent indefinite loading + private func refreshWithTimeout() { + viewModel.loadMyResidences(forceRefresh: true) + // Safety timeout: if the API hangs, clear loading state after 10 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + if viewModel.isLoading { + viewModel.isLoading = false + } + } + } + private func navigateToResidenceFromPush(residenceId: Int) { pushTargetResidenceId = Int32(residenceId) PushNotificationManager.shared.pendingNavigationResidenceId = nil diff --git a/iosApp/iosApp/RootView.swift b/iosApp/iosApp/RootView.swift index 9ab9733..01dff5f 100644 --- a/iosApp/iosApp/RootView.swift +++ b/iosApp/iosApp/RootView.swift @@ -11,7 +11,10 @@ class AuthenticationManager: ObservableObject { @Published var isCheckingAuth: Bool = true private init() { - checkAuthenticationStatus() + // NOTE: Do NOT call checkAuthenticationStatus() here. + // AuthenticationManager.shared may be initialized before DataManager.initialize() + // completes in iOSApp.init(), causing a race condition. Instead, RootView + // triggers the auth check via .task {} after the view appears. } func checkAuthenticationStatus() { @@ -200,6 +203,14 @@ struct RootView: View { .frame(width: 1, height: 1) .accessibilityIdentifier("ui.app.ready") } + .task { + // Trigger auth check here, after iOSApp.init() has completed + // DataManager.initialize(). This avoids the race condition where + // checkAuthenticationStatus() runs before DataManager is ready. + if authManager.isCheckingAuth { + authManager.checkAuthenticationStatus() + } + } } private var loadingView: some View { diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index 6cb86fb..8b4fe86 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -58,8 +58,12 @@ struct AllTasksView: View { if let task = pendingCompletedTask { startCompletionAnimation(for: task) } else { - taskViewModel.isAnimatingCompletion = false - loadAllTasks(forceRefresh: true) + // Delay clearing animation state so the sheet dismissal + // animation finishes before data refreshes move tasks + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + taskViewModel.isAnimatingCompletion = false + loadAllTasks(forceRefresh: true) + } } }) { task in CompleteTaskView(task: task) { updatedTask in @@ -280,7 +284,7 @@ struct AllTasksView: View { }) { OrganicToolbarAddButton() } - .disabled((residenceViewModel.myResidences?.residences.isEmpty ?? true)) + .disabled((residenceViewModel.myResidences?.residences.isEmpty ?? true) || showAddTask) .accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton) .accessibilityLabel("Add new task") } diff --git a/iosApp/iosApp/Task/TaskFormView.swift b/iosApp/iosApp/Task/TaskFormView.swift index ee618b7..1212476 100644 --- a/iosApp/iosApp/Task/TaskFormView.swift +++ b/iosApp/iosApp/Task/TaskFormView.swift @@ -46,6 +46,7 @@ struct TaskFormView: View { @State private var selectedFrequency: TaskFrequency? @State private var selectedPriority: TaskPriority? @State private var inProgress: Bool + @State private var hasDueDate: Bool @State private var dueDate: Date @State private var intervalDays: String @State private var estimatedCost: String @@ -67,7 +68,9 @@ struct TaskFormView: View { _inProgress = State(initialValue: task.inProgress) // Parse date from string - use effective due date (nextDueDate if set, otherwise dueDate) - _dueDate = State(initialValue: (task.effectiveDueDate ?? "").toDate() ?? Date()) + let parsedDate = (task.effectiveDueDate ?? "").toDate() + _hasDueDate = State(initialValue: parsedDate != nil) + _dueDate = State(initialValue: parsedDate ?? Date()) _intervalDays = State(initialValue: task.customIntervalDays.map { "\($0.int32Value)" } ?? "") _estimatedCost = State(initialValue: task.estimatedCost != nil ? String(task.estimatedCost!.doubleValue) : "") @@ -75,6 +78,7 @@ struct TaskFormView: View { _title = State(initialValue: "") _description = State(initialValue: "") _inProgress = State(initialValue: false) + _hasDueDate = State(initialValue: true) _dueDate = State(initialValue: Date()) _intervalDays = State(initialValue: "") _estimatedCost = State(initialValue: "") @@ -234,8 +238,12 @@ struct TaskFormView: View { .accessibilityIdentifier(AccessibilityIdentifiers.Task.intervalDaysField) } - DatePicker(L10n.Tasks.dueDate, selection: $dueDate, displayedComponents: .date) - .accessibilityIdentifier(AccessibilityIdentifiers.Task.dueDatePicker) + Toggle(L10n.Tasks.dueDate, isOn: $hasDueDate) + + if hasDueDate { + DatePicker(L10n.Tasks.dueDate, selection: $dueDate, displayedComponents: .date) + .accessibilityIdentifier(AccessibilityIdentifiers.Task.dueDatePicker) + } } header: { Text(L10n.Tasks.scheduling) .accessibilityAddTraits(.isHeader) @@ -328,6 +336,7 @@ struct TaskFormView: View { } .onChange(of: viewModel.taskCreated) { _, created in if created { + viewModel.resetActionState() isPresented = false } } @@ -464,8 +473,8 @@ struct TaskFormView: View { return } - // Format date as yyyy-MM-dd using extension - let dueDateString = dueDate.formattedAPI() + // Format date as yyyy-MM-dd using extension, or nil if no due date + let dueDateString: String? = hasDueDate ? dueDate.formattedAPI() : nil if isEditMode, let task = existingTask { // UPDATE existing task diff --git a/iosApp/iosApp/Task/TaskViewModel.swift b/iosApp/iosApp/Task/TaskViewModel.swift index 7c71473..312ddb7 100644 --- a/iosApp/iosApp/Task/TaskViewModel.swift +++ b/iosApp/iosApp/Task/TaskViewModel.swift @@ -72,6 +72,21 @@ class TaskViewModel: ObservableObject { } } .store(in: &cancellables) + + // Auto-reset stale success states after 3 seconds + $actionState + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + if case .success = state { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + // Only reset if still in the same success state + if self?.actionState == state { + self?.actionState = .idle + } + } + } + } + .store(in: &cancellables) } // MARK: - Public Methods @@ -258,6 +273,12 @@ class TaskViewModel: ObservableObject { errorMessage = nil } + /// Resets the action state to idle, clearing any stale success/error messages. + /// Call this after consuming a success state (e.g., after dismissing a form). + func resetActionState() { + actionState = .idle + } + // MARK: - Task Completions func loadCompletions(taskId: Int32) {