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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user