882801c71d
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
486 lines
19 KiB
Swift
486 lines
19 KiB
Swift
import Foundation
|
|
import ComposeApp
|
|
import Combine
|
|
|
|
/// ViewModel for task management.
|
|
/// Observes DataManagerObservable for cached data.
|
|
/// Calls APILayer directly for all operations.
|
|
@MainActor
|
|
class TaskViewModel: ObservableObject {
|
|
// MARK: - Published Properties (from DataManager observation)
|
|
@Published var tasksResponse: TaskColumnsResponse?
|
|
|
|
/// When true, DataManager observation is paused to allow completion animation to play
|
|
/// without the task being moved out of its column prematurely.
|
|
var isAnimatingCompletion = false
|
|
|
|
// MARK: - Local State
|
|
@Published var actionState: ActionState<TaskActionType> = .idle
|
|
@Published var errorMessage: String?
|
|
@Published var completions: [TaskCompletionResponse] = []
|
|
@Published var isLoadingCompletions: Bool = false
|
|
@Published var completionsError: String?
|
|
@Published var isLoadingTasks: Bool = false
|
|
@Published var tasksError: String?
|
|
|
|
/// The residence ID this kanban is filtered to (nil = all tasks)
|
|
private(set) var currentResidenceId: Int32?
|
|
|
|
// MARK: - Computed Properties (Backward Compatibility)
|
|
|
|
var isLoading: Bool { actionState.isLoading }
|
|
var taskCreated: Bool { actionState.isSuccess(.create) }
|
|
var taskUpdated: Bool { actionState.isSuccess(.update) }
|
|
var taskCancelled: Bool { actionState.isSuccess(.cancel) }
|
|
var taskUncancelled: Bool { actionState.isSuccess(.uncancel) }
|
|
var taskMarkedInProgress: Bool { actionState.isSuccess(.markInProgress) }
|
|
var taskArchived: Bool { actionState.isSuccess(.archive) }
|
|
var taskUnarchived: Bool { actionState.isSuccess(.unarchive) }
|
|
|
|
// MARK: - Private Properties
|
|
private var cancellables = Set<AnyCancellable>()
|
|
private let dataManager: DataManagerObservable
|
|
|
|
// 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.
|
|
/// Defaults to the shared singleton. Tests inject a fixture-backed
|
|
/// instance so populated-state snapshots render real data.
|
|
init(dataManager: DataManagerObservable = .shared) {
|
|
self.dataManager = dataManager
|
|
|
|
// Seed from current cache so snapshot tests/previews render
|
|
// 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
|
|
|
|
// Observe injected DataManagerObservable for all tasks data.
|
|
dataManager.$allTasks
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] allTasks in
|
|
guard let self else { return }
|
|
guard !self.isAnimatingCompletion else { return }
|
|
|
|
if let allTasks {
|
|
if let resId = self.currentResidenceId {
|
|
self.tasksResponse = self.filterTasks(allTasks, residenceId: resId)
|
|
} else {
|
|
self.tasksResponse = allTasks
|
|
}
|
|
self.isLoadingTasks = false
|
|
}
|
|
}
|
|
.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
|
|
func createTask(request: TaskCreateRequest, completion: @escaping (Bool) -> Void) {
|
|
actionState = .loading(.create)
|
|
errorMessage = nil
|
|
|
|
Task {
|
|
do {
|
|
let result = try await APILayer.shared.createTask(request: request)
|
|
|
|
if let error = ApiResultBridge.error(from: result) {
|
|
self.actionState = .error(.create, ErrorMessageParser.parse(error.message))
|
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
|
completion(false)
|
|
} else {
|
|
self.actionState = .success(.create)
|
|
completion(true)
|
|
}
|
|
} catch {
|
|
self.actionState = .error(.create, error.localizedDescription)
|
|
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
|
|
completion(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
func cancelTask(id: Int32, completion: @escaping (Bool) -> Void) {
|
|
actionState = .loading(.cancel)
|
|
errorMessage = nil
|
|
|
|
Task {
|
|
do {
|
|
let result = try await APILayer.shared.cancelTask(taskId: id)
|
|
|
|
// Check for error first, then treat non-error as success
|
|
// This handles Kotlin-Swift generic type bridging issues
|
|
if let error = ApiResultBridge.error(from: result) {
|
|
self.actionState = .error(.cancel, ErrorMessageParser.parse(error.message))
|
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
|
completion(false)
|
|
} else {
|
|
// Not an error = success (DataManager is updated by APILayer)
|
|
self.actionState = .success(.cancel)
|
|
completion(true)
|
|
}
|
|
} catch {
|
|
self.actionState = .error(.cancel, error.localizedDescription)
|
|
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
|
|
completion(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
func uncancelTask(id: Int32, completion: @escaping (Bool) -> Void) {
|
|
actionState = .loading(.uncancel)
|
|
errorMessage = nil
|
|
|
|
Task {
|
|
do {
|
|
let result = try await APILayer.shared.uncancelTask(taskId: id)
|
|
|
|
if let error = ApiResultBridge.error(from: result) {
|
|
self.actionState = .error(.uncancel, ErrorMessageParser.parse(error.message))
|
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
|
completion(false)
|
|
} else {
|
|
self.actionState = .success(.uncancel)
|
|
completion(true)
|
|
}
|
|
} catch {
|
|
self.actionState = .error(.uncancel, error.localizedDescription)
|
|
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
|
|
completion(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
func markInProgress(id: Int32, completion: @escaping (Bool) -> Void) {
|
|
actionState = .loading(.markInProgress)
|
|
errorMessage = nil
|
|
|
|
Task {
|
|
do {
|
|
let result = try await APILayer.shared.markInProgress(taskId: id)
|
|
|
|
if let error = ApiResultBridge.error(from: result) {
|
|
self.actionState = .error(.markInProgress, ErrorMessageParser.parse(error.message))
|
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
|
completion(false)
|
|
} else {
|
|
self.actionState = .success(.markInProgress)
|
|
completion(true)
|
|
}
|
|
} catch {
|
|
self.actionState = .error(.markInProgress, error.localizedDescription)
|
|
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
|
|
completion(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
func archiveTask(id: Int32, completion: @escaping (Bool) -> Void) {
|
|
actionState = .loading(.archive)
|
|
errorMessage = nil
|
|
|
|
Task {
|
|
do {
|
|
let result = try await APILayer.shared.archiveTask(taskId: id)
|
|
|
|
if let error = ApiResultBridge.error(from: result) {
|
|
self.actionState = .error(.archive, ErrorMessageParser.parse(error.message))
|
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
|
completion(false)
|
|
} else {
|
|
self.actionState = .success(.archive)
|
|
completion(true)
|
|
}
|
|
} catch {
|
|
self.actionState = .error(.archive, error.localizedDescription)
|
|
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
|
|
completion(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
func unarchiveTask(id: Int32, completion: @escaping (Bool) -> Void) {
|
|
actionState = .loading(.unarchive)
|
|
errorMessage = nil
|
|
|
|
Task {
|
|
do {
|
|
let result = try await APILayer.shared.unarchiveTask(taskId: id)
|
|
|
|
if let error = ApiResultBridge.error(from: result) {
|
|
self.actionState = .error(.unarchive, ErrorMessageParser.parse(error.message))
|
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
|
completion(false)
|
|
} else {
|
|
self.actionState = .success(.unarchive)
|
|
completion(true)
|
|
}
|
|
} catch {
|
|
self.actionState = .error(.unarchive, error.localizedDescription)
|
|
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
|
|
completion(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateTask(id: Int32, request: TaskCreateRequest, completion: @escaping (Bool) -> Void) {
|
|
actionState = .loading(.update)
|
|
errorMessage = nil
|
|
|
|
Task {
|
|
do {
|
|
let result = try await APILayer.shared.updateTask(id: id, request: request)
|
|
|
|
if let error = ApiResultBridge.error(from: result) {
|
|
self.actionState = .error(.update, ErrorMessageParser.parse(error.message))
|
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
|
completion(false)
|
|
} else {
|
|
self.actionState = .success(.update)
|
|
completion(true)
|
|
}
|
|
} catch {
|
|
self.actionState = .error(.update, error.localizedDescription)
|
|
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
|
|
completion(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
func clearError() {
|
|
errorMessage = nil
|
|
if case .error = actionState {
|
|
actionState = .idle
|
|
}
|
|
}
|
|
|
|
func resetState() {
|
|
actionState = .idle
|
|
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) {
|
|
isLoadingCompletions = true
|
|
completionsError = nil
|
|
|
|
Task {
|
|
do {
|
|
let result = try await APILayer.shared.getTaskCompletions(taskId: taskId)
|
|
|
|
if let success = result as? ApiResultSuccess<NSArray> {
|
|
self.completions = (success.data as? [TaskCompletionResponse]) ?? []
|
|
self.isLoadingCompletions = false
|
|
} else if let error = ApiResultBridge.error(from: result) {
|
|
self.completionsError = ErrorMessageParser.parse(error.message)
|
|
self.isLoadingCompletions = false
|
|
}
|
|
} catch {
|
|
self.completionsError = ErrorMessageParser.parse(error.localizedDescription)
|
|
self.isLoadingCompletions = false
|
|
}
|
|
}
|
|
}
|
|
|
|
func resetCompletionsState() {
|
|
completions = []
|
|
completionsError = nil
|
|
isLoadingCompletions = false
|
|
}
|
|
|
|
// MARK: - Kanban Board Methods
|
|
|
|
/// Count total tasks across all columns
|
|
var totalTaskCount: Int {
|
|
guard let response = tasksResponse else { return 0 }
|
|
return response.columns.reduce(0) { $0 + $1.tasks.count }
|
|
}
|
|
|
|
/// Check if there are no tasks
|
|
var hasNoTasks: Bool {
|
|
guard let response = tasksResponse else { return true }
|
|
return response.columns.allSatisfy { $0.tasks.isEmpty }
|
|
}
|
|
|
|
/// Check if there are tasks
|
|
var hasTasks: Bool {
|
|
!hasNoTasks
|
|
}
|
|
|
|
/// Load tasks - either all tasks or filtered by residence
|
|
/// Kotlin's APILayer handles caching - returns cached data if valid without network call.
|
|
/// - Parameters:
|
|
/// - residenceId: Optional residence ID to filter by. If nil, loads all tasks.
|
|
/// - forceRefresh: Whether to bypass cache
|
|
func loadTasks(residenceId: Int32? = nil, forceRefresh: Bool = false) {
|
|
guard DataManager.shared.isAuthenticated() else { return }
|
|
|
|
currentResidenceId = residenceId
|
|
tasksError = nil
|
|
isLoadingTasks = true
|
|
|
|
// Kick off API call - DataManager will be updated, which updates DataManagerObservable,
|
|
// which updates our @Published tasksResponse via the sink above
|
|
Task {
|
|
do {
|
|
let result: Any
|
|
if let resId = residenceId {
|
|
result = try await APILayer.shared.getTasksByResidence(
|
|
residenceId: resId,
|
|
forceRefresh: forceRefresh
|
|
)
|
|
} else {
|
|
result = try await APILayer.shared.getTasks(forceRefresh: forceRefresh)
|
|
}
|
|
|
|
// Handle all result states
|
|
await MainActor.run {
|
|
if let success = result as? ApiResultSuccess<TaskColumnsResponse>,
|
|
let data = success.data {
|
|
// Update widget data if loading all tasks
|
|
if residenceId == nil {
|
|
WidgetDataManager.shared.saveTasks(from: data)
|
|
}
|
|
// tasksResponse is updated via DataManagerObservable observation
|
|
// Ensure loading state is cleared on success
|
|
self.isLoadingTasks = false
|
|
} else if let error = ApiResultBridge.error(from: result) {
|
|
self.tasksError = error.message
|
|
self.isLoadingTasks = false
|
|
} else {
|
|
self.tasksError = "Failed to load tasks"
|
|
self.isLoadingTasks = false
|
|
}
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
self.tasksError = ErrorMessageParser.parse(error.localizedDescription)
|
|
self.isLoadingTasks = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
func updateTaskInKanban(_ updatedTask: TaskResponse) {
|
|
guard let currentResponse = tasksResponse else { return }
|
|
|
|
let targetColumn = updatedTask.kanbanColumn ?? "completed_tasks"
|
|
|
|
// Build new columns array
|
|
var newColumns: [TaskColumn] = []
|
|
|
|
for column in currentResponse.columns {
|
|
// Remove task from this column if it exists
|
|
var filteredTasks = column.tasks.filter { $0.id != updatedTask.id }
|
|
|
|
// Add task to target column
|
|
if column.name == targetColumn {
|
|
filteredTasks.append(updatedTask)
|
|
}
|
|
|
|
// Create new column with updated tasks and count
|
|
let newColumn = TaskColumn(
|
|
name: column.name,
|
|
displayName: column.displayName,
|
|
buttonTypes: column.buttonTypes,
|
|
icons: column.icons,
|
|
color: column.color,
|
|
tasks: filteredTasks,
|
|
count: Int32(filteredTasks.count)
|
|
)
|
|
newColumns.append(newColumn)
|
|
}
|
|
|
|
// Update the response
|
|
tasksResponse = TaskColumnsResponse(
|
|
columns: newColumns,
|
|
daysThreshold: currentResponse.daysThreshold,
|
|
residenceId: currentResponse.residenceId
|
|
)
|
|
}
|
|
|
|
/// Removes a task from the kanban board (after deletion)
|
|
func removeTaskFromKanban(taskId: Int32) {
|
|
guard let currentResponse = tasksResponse else { return }
|
|
|
|
var newColumns: [TaskColumn] = []
|
|
|
|
for column in currentResponse.columns {
|
|
let filteredTasks = column.tasks.filter { $0.id != taskId }
|
|
|
|
let newColumn = TaskColumn(
|
|
name: column.name,
|
|
displayName: column.displayName,
|
|
buttonTypes: column.buttonTypes,
|
|
icons: column.icons,
|
|
color: column.color,
|
|
tasks: filteredTasks,
|
|
count: Int32(filteredTasks.count)
|
|
)
|
|
newColumns.append(newColumn)
|
|
}
|
|
|
|
tasksResponse = TaskColumnsResponse(
|
|
columns: newColumns,
|
|
daysThreshold: currentResponse.daysThreshold,
|
|
residenceId: currentResponse.residenceId
|
|
)
|
|
}
|
|
|
|
/// Reloads the kanban board with current settings
|
|
func reloadTasks() {
|
|
loadTasks(residenceId: currentResidenceId, forceRefresh: true)
|
|
}
|
|
}
|