Fix 12 iOS issues: race conditions, data flow, UX

Critical bugs:
- RootView: auth check deferred to .task{} modifier (after DataManager init)
- DataManagerObservable: map conversion failures now logged with key details
- ContractorViewModel: replace stuck boolean flag with time-based suppression
- DocumentViewModel: guard full success.data before image upload

Logic fixes:
- AllTasksView: 300ms delay before animation flag release
- ResidenceViewModel: trigger initializeLookups() if not ready
- TaskFormView: hasDueDate toggle prevents defaulting to today
- OnboardingState: guard isAuthenticated before completing onboarding

UX fixes:
- ResidencesListView: 10-second refresh timeout
- AllTasksView: add button disabled while sheet presented
- TaskViewModel: actionState auto-resets after 3s, explicit reset on consume
This commit is contained in:
Trey T
2026-03-26 18:01:49 -05:00
parent 4d363ca44e
commit 8f86fa2cd0
10 changed files with 119 additions and 21 deletions

View File

@@ -19,9 +19,10 @@ class ContractorViewModel: ObservableObject {
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
/// 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<Contractor> {
// 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) {

View File

@@ -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
}

View File

@@ -130,14 +130,23 @@ class DocumentViewModel: ObservableObject {
if let success = result as? ApiResultSuccess<Document> {
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)

View File

@@ -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 = ""

View File

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

View File

@@ -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

View File

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

View File

@@ -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")
}

View File

@@ -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

View File

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