Files
honeyDueKMP/iosApp/iosApp/Task/TaskViewModel.swift
Trey t 258ccf7354 Improve error message handling with user-friendly messages
- Add ErrorMessageParser in Kotlin and Swift to detect network errors
  and technical messages, replacing them with human-readable text
- Update all ViewModels to use ErrorMessageParser.parse() for error display
- Remove redundant error popup from LoginView (error shows inline only)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 20:46:43 -06:00

442 lines
16 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?
// 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>()
// MARK: - Initialization
init() {
// Observe DataManagerObservable for all tasks data
DataManagerObservable.shared.$allTasks
.receive(on: DispatchQueue.main)
.sink { [weak self] allTasks in
// 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
DataManagerObservable.shared.$tasksByResidence
.receive(on: DispatchQueue.main)
.sink { [weak self] tasksByResidence in
// Only update if we're filtering by residence
if let resId = self?.currentResidenceId,
let tasks = tasksByResidence[resId] {
self?.tasksResponse = tasks
self?.isLoadingTasks = false
}
}
.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 = result as? ApiResultError {
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 = result as? ApiResultError {
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 = result as? ApiResultError {
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 = result as? ApiResultError {
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 = result as? ApiResultError {
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 = result as? ApiResultError {
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 = result as? ApiResultError {
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
}
// 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 = result as? ApiResultError {
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
/// Checks cache first, then fetches if needed.
/// - 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
// Check if we have cached data and don't need to refresh
if !forceRefresh {
if let resId = residenceId {
if DataManagerObservable.shared.tasksByResidence[resId] != nil {
// Data already available via observation, no API call needed
return
}
} else if DataManagerObservable.shared.allTasks != nil {
// Data already available via observation, no API call needed
return
}
}
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 = result as? ApiResultError {
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
}
}
}
}
/// 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, summary: nil
)
}
/// 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, summary: nil
)
}
/// Reloads the kanban board with current settings
func reloadTasks() {
loadTasks(residenceId: currentResidenceId, forceRefresh: true)
}
}