b2d03ef8b2
Android UI Tests / ui-tests (pull_request) Has been cancelled
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>
124 lines
5.1 KiB
Swift
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)")
|
|
}
|
|
}
|
|
}
|