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:
Trey t
2026-04-25 10:43:11 -05:00
parent 4181b6005d
commit ce25c80783
+36 -23
View File
@@ -42,33 +42,24 @@ class TaskViewModel: ObservableObject {
// MARK: - Initialization // MARK: - Initialization
init() { init() {
// Observe DataManagerObservable for all tasks data // 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. Phase 3 deletes the per-residence cache entirely.
DataManagerObservable.shared.$allTasks DataManagerObservable.shared.$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 {
DataManagerObservable.shared.$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)
@@ -382,6 +373,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 }