Add 1-hour cache timeout and fix pull-to-refresh across iOS
- Add configurable cache timeout (CACHE_TIMEOUT_MS) to DataManager - Fix cache to work with empty results (contractors, documents, residences) - Change Documents/Warranties view to use client-side filtering for cache efficiency - Add pull-to-refresh support for empty state views in ListAsyncContentView - Fix ContractorsListView to pass forceRefresh parameter correctly - Fix TaskViewModel loading spinner not stopping after refresh completes - Remove duplicate cache checks in iOS ViewModels, delegate to Kotlin APILayer 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,17 +2,20 @@ 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
|
||||
// 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?
|
||||
|
||||
// MARK: - Kanban Board State (shared across views)
|
||||
@Published var tasksResponse: TaskColumnsResponse?
|
||||
@Published var isLoadingTasks: Bool = false
|
||||
@Published var tasksError: String?
|
||||
|
||||
@@ -31,11 +34,36 @@ class TaskViewModel: ObservableObject {
|
||||
var taskUnarchived: Bool { actionState.isSuccess(.unarchive) }
|
||||
|
||||
// MARK: - Private Properties
|
||||
private let sharedViewModel: ComposeApp.TaskViewModel
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Initialization
|
||||
init(sharedViewModel: ComposeApp.TaskViewModel? = nil) {
|
||||
self.sharedViewModel = sharedViewModel ?? ComposeApp.TaskViewModel()
|
||||
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
|
||||
@@ -43,42 +71,48 @@ class TaskViewModel: ObservableObject {
|
||||
actionState = .loading(.create)
|
||||
errorMessage = nil
|
||||
|
||||
sharedViewModel.createNewTask(request: request)
|
||||
Task {
|
||||
do {
|
||||
let result = try await APILayer.shared.createTask(request: request)
|
||||
|
||||
StateFlowObserver.observeWithCompletion(
|
||||
sharedViewModel.taskAddNewCustomTaskState,
|
||||
loadingSetter: { [weak self] loading in
|
||||
if loading { self?.actionState = .loading(.create) }
|
||||
},
|
||||
errorSetter: { [weak self] error in
|
||||
if let error = error {
|
||||
self?.actionState = .error(.create, error)
|
||||
self?.errorMessage = error
|
||||
if result is ApiResultSuccess<TaskResponse> {
|
||||
self.actionState = .success(.create)
|
||||
// DataManager is updated by APILayer, view updates via observation
|
||||
completion(true)
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.actionState = .error(.create, ErrorMessageParser.parse(error.message))
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
completion(false)
|
||||
}
|
||||
},
|
||||
onSuccess: { [weak self] (_: TaskResponse) in
|
||||
self?.actionState = .success(.create)
|
||||
},
|
||||
completion: completion,
|
||||
resetState: { [weak self] in self?.sharedViewModel.resetAddTaskState() }
|
||||
)
|
||||
} catch {
|
||||
self.actionState = .error(.create, error.localizedDescription)
|
||||
self.errorMessage = error.localizedDescription
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancelTask(id: Int32, completion: @escaping (Bool) -> Void) {
|
||||
actionState = .loading(.cancel)
|
||||
errorMessage = nil
|
||||
|
||||
sharedViewModel.cancelTask(taskId: id) { success in
|
||||
Task { @MainActor in
|
||||
if success.boolValue {
|
||||
Task {
|
||||
do {
|
||||
let result = try await APILayer.shared.cancelTask(taskId: id)
|
||||
|
||||
if result is ApiResultSuccess<TaskResponse> {
|
||||
self.actionState = .success(.cancel)
|
||||
// DataManager is updated by APILayer, view updates via observation
|
||||
completion(true)
|
||||
} else {
|
||||
let errorMsg = "Failed to cancel task"
|
||||
self.actionState = .error(.cancel, errorMsg)
|
||||
self.errorMessage = errorMsg
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.actionState = .error(.cancel, ErrorMessageParser.parse(error.message))
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
completion(false)
|
||||
}
|
||||
} catch {
|
||||
self.actionState = .error(.cancel, error.localizedDescription)
|
||||
self.errorMessage = error.localizedDescription
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,17 +121,23 @@ class TaskViewModel: ObservableObject {
|
||||
actionState = .loading(.uncancel)
|
||||
errorMessage = nil
|
||||
|
||||
sharedViewModel.uncancelTask(taskId: id) { success in
|
||||
Task { @MainActor in
|
||||
if success.boolValue {
|
||||
Task {
|
||||
do {
|
||||
let result = try await APILayer.shared.uncancelTask(taskId: id)
|
||||
|
||||
if result is ApiResultSuccess<TaskResponse> {
|
||||
self.actionState = .success(.uncancel)
|
||||
// DataManager is updated by APILayer, view updates via observation
|
||||
completion(true)
|
||||
} else {
|
||||
let errorMsg = "Failed to uncancel task"
|
||||
self.actionState = .error(.uncancel, errorMsg)
|
||||
self.errorMessage = errorMsg
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.actionState = .error(.uncancel, ErrorMessageParser.parse(error.message))
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
completion(false)
|
||||
}
|
||||
} catch {
|
||||
self.actionState = .error(.uncancel, error.localizedDescription)
|
||||
self.errorMessage = error.localizedDescription
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,17 +146,23 @@ class TaskViewModel: ObservableObject {
|
||||
actionState = .loading(.markInProgress)
|
||||
errorMessage = nil
|
||||
|
||||
sharedViewModel.markInProgress(taskId: id) { success in
|
||||
Task { @MainActor in
|
||||
if success.boolValue {
|
||||
Task {
|
||||
do {
|
||||
let result = try await APILayer.shared.markInProgress(taskId: id)
|
||||
|
||||
if result is ApiResultSuccess<TaskResponse> {
|
||||
self.actionState = .success(.markInProgress)
|
||||
// DataManager is updated by APILayer, view updates via observation
|
||||
completion(true)
|
||||
} else {
|
||||
let errorMsg = "Failed to mark task in progress"
|
||||
self.actionState = .error(.markInProgress, errorMsg)
|
||||
self.errorMessage = errorMsg
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.actionState = .error(.markInProgress, ErrorMessageParser.parse(error.message))
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
completion(false)
|
||||
}
|
||||
} catch {
|
||||
self.actionState = .error(.markInProgress, error.localizedDescription)
|
||||
self.errorMessage = error.localizedDescription
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,17 +171,23 @@ class TaskViewModel: ObservableObject {
|
||||
actionState = .loading(.archive)
|
||||
errorMessage = nil
|
||||
|
||||
sharedViewModel.archiveTask(taskId: id) { success in
|
||||
Task { @MainActor in
|
||||
if success.boolValue {
|
||||
Task {
|
||||
do {
|
||||
let result = try await APILayer.shared.archiveTask(taskId: id)
|
||||
|
||||
if result is ApiResultSuccess<TaskResponse> {
|
||||
self.actionState = .success(.archive)
|
||||
// DataManager is updated by APILayer, view updates via observation
|
||||
completion(true)
|
||||
} else {
|
||||
let errorMsg = "Failed to archive task"
|
||||
self.actionState = .error(.archive, errorMsg)
|
||||
self.errorMessage = errorMsg
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.actionState = .error(.archive, ErrorMessageParser.parse(error.message))
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
completion(false)
|
||||
}
|
||||
} catch {
|
||||
self.actionState = .error(.archive, error.localizedDescription)
|
||||
self.errorMessage = error.localizedDescription
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,17 +196,23 @@ class TaskViewModel: ObservableObject {
|
||||
actionState = .loading(.unarchive)
|
||||
errorMessage = nil
|
||||
|
||||
sharedViewModel.unarchiveTask(taskId: id) { success in
|
||||
Task { @MainActor in
|
||||
if success.boolValue {
|
||||
Task {
|
||||
do {
|
||||
let result = try await APILayer.shared.unarchiveTask(taskId: id)
|
||||
|
||||
if result is ApiResultSuccess<TaskResponse> {
|
||||
self.actionState = .success(.unarchive)
|
||||
// DataManager is updated by APILayer, view updates via observation
|
||||
completion(true)
|
||||
} else {
|
||||
let errorMsg = "Failed to unarchive task"
|
||||
self.actionState = .error(.unarchive, errorMsg)
|
||||
self.errorMessage = errorMsg
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.actionState = .error(.unarchive, ErrorMessageParser.parse(error.message))
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
completion(false)
|
||||
}
|
||||
} catch {
|
||||
self.actionState = .error(.unarchive, error.localizedDescription)
|
||||
self.errorMessage = error.localizedDescription
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,17 +221,23 @@ class TaskViewModel: ObservableObject {
|
||||
actionState = .loading(.update)
|
||||
errorMessage = nil
|
||||
|
||||
sharedViewModel.updateTask(taskId: id, request: request) { success in
|
||||
Task { @MainActor in
|
||||
if success.boolValue {
|
||||
Task {
|
||||
do {
|
||||
let result = try await APILayer.shared.updateTask(id: id, request: request)
|
||||
|
||||
if result is ApiResultSuccess<TaskResponse> {
|
||||
self.actionState = .success(.update)
|
||||
// DataManager is updated by APILayer, view updates via observation
|
||||
completion(true)
|
||||
} else {
|
||||
let errorMsg = "Failed to update task"
|
||||
self.actionState = .error(.update, errorMsg)
|
||||
self.errorMessage = errorMsg
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.actionState = .error(.update, ErrorMessageParser.parse(error.message))
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
completion(false)
|
||||
}
|
||||
} catch {
|
||||
self.actionState = .error(.update, error.localizedDescription)
|
||||
self.errorMessage = error.localizedDescription
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,27 +260,20 @@ class TaskViewModel: ObservableObject {
|
||||
isLoadingCompletions = true
|
||||
completionsError = nil
|
||||
|
||||
sharedViewModel.loadTaskCompletions(taskId: taskId)
|
||||
|
||||
Task {
|
||||
for await state in sharedViewModel.taskCompletionsState {
|
||||
if let success = state as? ApiResultSuccess<NSArray> {
|
||||
await MainActor.run {
|
||||
self.completions = (success.data as? [TaskCompletionResponse]) ?? []
|
||||
self.isLoadingCompletions = false
|
||||
}
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.completionsError = error.message
|
||||
self.isLoadingCompletions = false
|
||||
}
|
||||
break
|
||||
} else if state is ApiResultLoading {
|
||||
await MainActor.run {
|
||||
self.isLoadingCompletions = true
|
||||
}
|
||||
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 = error.localizedDescription
|
||||
self.isLoadingCompletions = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,7 +282,6 @@ class TaskViewModel: ObservableObject {
|
||||
completions = []
|
||||
completionsError = nil
|
||||
isLoadingCompletions = false
|
||||
sharedViewModel.resetTaskCompletionsState()
|
||||
}
|
||||
|
||||
// MARK: - Kanban Board Methods
|
||||
@@ -248,6 +304,7 @@ class TaskViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -255,9 +312,25 @@ class TaskViewModel: ObservableObject {
|
||||
guard DataManager.shared.isAuthenticated() else { return }
|
||||
|
||||
currentResidenceId = residenceId
|
||||
isLoadingTasks = true
|
||||
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
|
||||
@@ -270,17 +343,17 @@ class TaskViewModel: ObservableObject {
|
||||
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 {
|
||||
self.tasksResponse = data
|
||||
self.isLoadingTasks = false
|
||||
self.tasksError = nil
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user