Files
honeyDueKMP/iosApp/iosApp/Helpers/WidgetActionProcessor.swift
T
Trey t b2d03ef8b2
Android UI Tests / ui-tests (pull_request) Has been cancelled
refactor(uploads): drop legacy multipart helpers; route Android UI through presigned flow
The KMP shared layer's task-completion-with-images path now exclusively
uses the presigned-URL flow: each image is compressed, uploaded directly
to B2 via APILayer.uploadImage, and the resulting upload_ids are passed
to /api/task-completions/ as JSON. Bytes never traverse our API server.

Changes:
  - TaskCompletionViewModel.createTaskCompletionWithImages now does the
    presign→POST→collect-ids dance internally. The signature stays the
    same so the three Android UI call sites (TasksScreen, AllTasksScreen,
    ResidenceDetailScreen, CompleteTaskDialog, CompleteTaskScreen) need
    no changes.
  - APILayer.createTaskCompletionWithImages removed (dead).
  - TaskCompletionApi.createCompletionWithImages removed (the multipart
    HTTP helper that posted to the legacy POST /api/task-completions/
    multipart endpoint).
  - TaskCompletionCreateRequest.imageUrls field removed.
  - Three Swift call sites (CompleteTaskView, WidgetActionProcessor,
    PushNotificationManager) updated to drop the imageUrls argument.
  - Two Kotlin call sites (CompleteTaskDialog, CompleteTaskScreen) updated.

Image uploads now match WhatsApp/Slack-class architecture: client-side
compression + direct-to-storage upload + lightweight JSON entity create.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:48:11 -07:00

124 lines
5.1 KiB
Swift

import Foundation
import ComposeApp
import WidgetKit
/// Processes pending actions queued by the widget extension
/// Call `processPendingActions()` when the app becomes active
@MainActor
final class WidgetActionProcessor {
static let shared = WidgetActionProcessor()
/// Maximum number of retry attempts per action before giving up
private static let maxRetries = 3
/// Tracks retry counts by action description (taskId)
private var retryCounts: [Int: Int] = [:]
private init() {}
/// Check if there are pending widget actions to process
var hasPendingActions: Bool {
WidgetDataManager.shared.hasPendingActions
}
/// Process all pending widget actions
/// Should be called when app becomes active
func processPendingActions() {
guard DataManager.shared.isAuthenticated() else {
print("WidgetActionProcessor: Not authenticated, skipping action processing")
return
}
Task {
let actions = await WidgetDataManager.shared.loadPendingActions()
guard !actions.isEmpty else {
print("WidgetActionProcessor: No pending actions")
return
}
print("WidgetActionProcessor: Processing \(actions.count) pending action(s)")
for action in actions {
await processAction(action)
}
}
}
/// Process a single widget action
private func processAction(_ action: WidgetDataManager.WidgetAction) async {
switch action {
case .completeTask(let taskId, let taskTitle):
await completeTask(taskId: taskId, taskTitle: taskTitle, action: action)
}
}
/// Complete a task via the API
private func completeTask(taskId: Int, taskTitle: String, action: WidgetDataManager.WidgetAction) async {
print("WidgetActionProcessor: Completing task \(taskId) - \(taskTitle)")
do {
// Create a task completion with default values (quick complete from widget)
let request = TaskCompletionCreateRequest(
taskId: Int32(taskId),
completedAt: nil, // Defaults to now on server
notes: "Completed from widget",
actualCost: nil,
rating: nil,
uploadIds: nil
)
let result = try await APILayer.shared.createTaskCompletion(request: request)
if result is ApiResultSuccess<TaskCompletionResponse> {
print("WidgetActionProcessor: Task \(taskId) completed successfully")
// Remove the processed action and clear pending state
WidgetDataManager.shared.removeAction(action)
WidgetDataManager.shared.clearPendingState(forTaskId: taskId)
retryCounts.removeValue(forKey: taskId)
// Refresh tasks to update UI
await refreshTasks()
} else if let error = ApiResultBridge.error(from: result) {
print("WidgetActionProcessor: Failed to complete task \(taskId): \(error.message)")
handleRetryOrDiscard(taskId: taskId, action: action, reason: error.message)
}
} catch {
print("WidgetActionProcessor: Error completing task \(taskId): \(error)")
handleRetryOrDiscard(taskId: taskId, action: action, reason: error.localizedDescription)
}
}
/// Increment retry count; discard action only after maxRetries.
/// On failure, clear pending state so the task reappears in the widget.
private func handleRetryOrDiscard(taskId: Int, action: WidgetDataManager.WidgetAction, reason: String) {
let attempts = (retryCounts[taskId] ?? 0) + 1
retryCounts[taskId] = attempts
if attempts >= Self.maxRetries {
print("WidgetActionProcessor: Task \(taskId) failed after \(attempts) attempts (\(reason)). Discarding action.")
WidgetDataManager.shared.removeAction(action)
WidgetDataManager.shared.clearPendingState(forTaskId: taskId)
retryCounts.removeValue(forKey: taskId)
} else {
print("WidgetActionProcessor: Task \(taskId) attempt \(attempts)/\(Self.maxRetries) failed (\(reason)). Keeping for retry.")
// Clear pending state so the task is visible in the widget again,
// but keep the action so it will be retried next time the app becomes active.
WidgetDataManager.shared.clearPendingState(forTaskId: taskId)
}
}
/// Refresh tasks from the server to update UI and widget
private func refreshTasks() async {
do {
let result = try await APILayer.shared.getTasks(forceRefresh: true)
if let success = result as? ApiResultSuccess<TaskColumnsResponse>,
let data = success.data {
// Update widget with fresh data
WidgetDataManager.shared.saveTasks(from: data)
// Summary is calculated client-side by DataManager.setAllTasks() -> refreshSummaryFromKanban()
}
} catch {
print("WidgetActionProcessor: Error refreshing tasks: \(error)")
}
}
}