ios: TaskViewModel observes $allTasks and filters by residence in-memory
Replaces the dual-sink ($allTasks when residence-scoped is nil, $tasksByResidence when set) with a single $allTasks observation that filters in-memory when currentResidenceId is set. Eliminates the gitea#2 race window where the per-residence cache slot could be empty while $allTasks was populated, leaving residence detail stuck on the empty state. After this commit, every emit of _allTasks rerenders every observing view — kanban tab, residence detail, dashboards — atomically. Refs gitea#2
This commit is contained in:
@@ -42,6 +42,12 @@ class TaskViewModel: ObservableObject {
|
|||||||
private let dataManager: DataManagerObservable
|
private let dataManager: DataManagerObservable
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
/// Single source of truth = DataManager._allTasks. When this VM is
|
||||||
|
/// residence-scoped (currentResidenceId set), filter in-memory by
|
||||||
|
/// residence id. Eliminates the gitea#2 race window where the
|
||||||
|
/// per-residence cache slot could be empty while _allTasks was
|
||||||
|
/// populated. The per-residence cache is gone (cec521b).
|
||||||
|
///
|
||||||
/// - Parameter dataManager: Observable cache the VM subscribes to.
|
/// - Parameter dataManager: Observable cache the VM subscribes to.
|
||||||
/// Defaults to the shared singleton. Tests inject a fixture-backed
|
/// Defaults to the shared singleton. Tests inject a fixture-backed
|
||||||
/// instance so populated-state snapshots render real data.
|
/// instance so populated-state snapshots render real data.
|
||||||
@@ -50,35 +56,26 @@ class TaskViewModel: ObservableObject {
|
|||||||
|
|
||||||
// Seed from current cache so snapshot tests/previews render
|
// Seed from current cache so snapshot tests/previews render
|
||||||
// populated state without waiting for Combine's async dispatch.
|
// populated state without waiting for Combine's async dispatch.
|
||||||
|
// The seed path mirrors the steady-state filter below — if this
|
||||||
|
// VM is residence-scoped at construction time the seed has to
|
||||||
|
// pre-filter too, but currentResidenceId is set after init via
|
||||||
|
// setResidenceFilter(...), so seeding the unfiltered list is fine.
|
||||||
self.tasksResponse = dataManager.allTasks
|
self.tasksResponse = dataManager.allTasks
|
||||||
|
|
||||||
// Observe injected DataManagerObservable for all tasks data
|
// Observe injected DataManagerObservable for all tasks data.
|
||||||
dataManager.$allTasks
|
dataManager.$allTasks
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] allTasks in
|
.sink { [weak self] allTasks in
|
||||||
// Skip DataManager updates during completion animation to prevent
|
guard let self else { return }
|
||||||
// the task from being moved out of its column before the animation finishes
|
guard !self.isAnimatingCompletion else { return }
|
||||||
guard self?.isAnimatingCompletion != true else { return }
|
|
||||||
// Only update if we're showing all tasks (no residence filter)
|
|
||||||
if self?.currentResidenceId == nil {
|
|
||||||
self?.tasksResponse = allTasks
|
|
||||||
if allTasks != nil {
|
|
||||||
self?.isLoadingTasks = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
// Observe tasks by residence
|
if let allTasks {
|
||||||
dataManager.$tasksByResidence
|
if let resId = self.currentResidenceId {
|
||||||
.receive(on: DispatchQueue.main)
|
self.tasksResponse = self.filterTasks(allTasks, residenceId: resId)
|
||||||
.sink { [weak self] tasksByResidence in
|
} else {
|
||||||
guard self?.isAnimatingCompletion != true else { return }
|
self.tasksResponse = allTasks
|
||||||
// Only update if we're filtering by residence
|
}
|
||||||
if let resId = self?.currentResidenceId,
|
self.isLoadingTasks = false
|
||||||
let tasks = tasksByResidence[resId] {
|
|
||||||
self?.tasksResponse = tasks
|
|
||||||
self?.isLoadingTasks = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
@@ -392,6 +389,28 @@ class TaskViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Filter the all-tasks kanban down to a single residence in-memory.
|
||||||
|
/// Mirrors `DataManager.getTasksForResidence` on the Kotlin side.
|
||||||
|
private func filterTasks(_ response: TaskColumnsResponse, residenceId: Int32) -> TaskColumnsResponse {
|
||||||
|
let filteredColumns = response.columns.map { column -> TaskColumn in
|
||||||
|
let filteredTasks = column.tasks.filter { Int32($0.residenceId) == residenceId }
|
||||||
|
return TaskColumn(
|
||||||
|
name: column.name,
|
||||||
|
displayName: column.displayName,
|
||||||
|
buttonTypes: column.buttonTypes,
|
||||||
|
icons: column.icons,
|
||||||
|
color: column.color,
|
||||||
|
tasks: filteredTasks,
|
||||||
|
count: Int32(filteredTasks.count)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return TaskColumnsResponse(
|
||||||
|
columns: filteredColumns,
|
||||||
|
daysThreshold: response.daysThreshold,
|
||||||
|
residenceId: String(residenceId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Updates a task in the kanban board by moving it to the correct column based on kanban_column
|
/// Updates a task in the kanban board by moving it to the correct column based on kanban_column
|
||||||
func updateTaskInKanban(_ updatedTask: TaskResponse) {
|
func updateTaskInKanban(_ updatedTask: TaskResponse) {
|
||||||
guard let currentResponse = tasksResponse else { return }
|
guard let currentResponse = tasksResponse else { return }
|
||||||
|
|||||||
Reference in New Issue
Block a user